expo-orb 0.1.0 → 0.2.0

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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +20 -0
  3. package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +247 -0
  4. package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +104 -0
  5. package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +41 -0
  6. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +156 -0
  7. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +123 -0
  8. package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
  9. package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
  10. package/build/ExpoBreathingExercise.types.d.ts +48 -0
  11. package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
  12. package/build/ExpoBreathingExercise.types.js +2 -0
  13. package/build/ExpoBreathingExercise.types.js.map +1 -0
  14. package/build/ExpoBreathingExerciseModule.d.ts +16 -0
  15. package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
  16. package/build/ExpoBreathingExerciseModule.js +52 -0
  17. package/build/ExpoBreathingExerciseModule.js.map +1 -0
  18. package/build/ExpoBreathingExerciseView.d.ts +4 -0
  19. package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
  20. package/build/ExpoBreathingExerciseView.js +7 -0
  21. package/build/ExpoBreathingExerciseView.js.map +1 -0
  22. package/build/index.d.ts +3 -0
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +3 -0
  25. package/build/index.js.map +1 -1
  26. package/expo-module.config.json +2 -2
  27. package/ios/Breathing/BreathingConfiguration.swift +57 -0
  28. package/ios/Breathing/BreathingExerciseView.swift +451 -0
  29. package/ios/Breathing/BreathingSharedState.swift +84 -0
  30. package/ios/Breathing/BreathingTextCue.swift +14 -0
  31. package/ios/Breathing/MorphingBlobView.swift +242 -0
  32. package/ios/Breathing/ProgressRingView.swift +27 -0
  33. package/ios/ExpoBreathingExerciseModule.swift +182 -0
  34. package/ios/ExpoBreathingExerciseView.swift +124 -0
  35. package/package.json +8 -5
  36. package/src/ExpoBreathingExercise.types.ts +50 -0
  37. package/src/ExpoBreathingExerciseModule.ts +67 -0
  38. package/src/ExpoBreathingExerciseView.tsx +11 -0
  39. package/src/index.ts +11 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ Inspired by and iOS implementation from [metasidd/Orb](https://github.com/metasi
8
8
 
9
9
  | iOS | Android |
10
10
  |:---:|:-------:|
11
- | ![iOS Demo](https://raw.githubusercontent.com/enso-works/expo-orb/main/docs/demo-ios.gif) | ![Android Demo](https://raw.githubusercontent.com/enso-works/expo-orb/main/docs/demo-android.gif) |
11
+ | ![iOS Demo](https://raw.githubusercontent.com/enso-works/expo-orb-animation/main/docs/demo-ios.gif) | ![Android Demo](https://raw.githubusercontent.com/enso-works/expo-orb-animation/main/docs/demo-android.gif) |
12
12
 
13
13
  ## Features
14
14
 
@@ -0,0 +1,20 @@
1
+ package expo.modules.breathing
2
+
3
+ import androidx.compose.ui.graphics.Color
4
+
5
+ data class BreathingConfiguration(
6
+ val blobColors: List<Color> = listOf(
7
+ Color(0xFF66B3E6), // Light blue
8
+ Color(0xFF4D80CC), // Medium blue
9
+ Color(0xFF804DB3) // Purple
10
+ ),
11
+ val innerBlobColor: Color = Color.White.copy(alpha = 0.3f),
12
+ val progressRingColor: Color = Color.White.copy(alpha = 0.5f),
13
+ val textColor: Color = Color.White,
14
+ val showProgressRing: Boolean = true,
15
+ val showTextCue: Boolean = true,
16
+ val showInnerBlob: Boolean = true,
17
+ val showShadow: Boolean = true,
18
+ val pointCount: Int = 28,
19
+ val wobbleIntensity: Double = 1.0
20
+ )
@@ -0,0 +1,247 @@
1
+ package expo.modules.breathing
2
+
3
+ import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.aspectRatio
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.runtime.Composable
7
+ import androidx.compose.runtime.LaunchedEffect
8
+ import androidx.compose.runtime.getValue
9
+ import androidx.compose.runtime.mutableLongStateOf
10
+ import androidx.compose.runtime.mutableStateOf
11
+ import androidx.compose.runtime.remember
12
+ import androidx.compose.runtime.setValue
13
+ import androidx.compose.runtime.withFrameNanos
14
+ import androidx.compose.ui.Alignment
15
+ import androidx.compose.ui.Modifier
16
+ import androidx.compose.ui.draw.drawBehind
17
+ import androidx.compose.ui.graphics.Color
18
+ import androidx.compose.ui.unit.dp
19
+ import kotlin.math.min
20
+ import kotlin.random.Random
21
+
22
+ @Composable
23
+ fun BreathingExerciseView(
24
+ config: BreathingConfiguration,
25
+ modifier: Modifier = Modifier
26
+ ) {
27
+ // Frame-based animation state
28
+ var frameTime by remember { mutableLongStateOf(System.nanoTime()) }
29
+
30
+ // Continuous animation loop
31
+ LaunchedEffect(Unit) {
32
+ while (true) {
33
+ withFrameNanos { currentTime ->
34
+ frameTime = currentTime
35
+ }
36
+ }
37
+ }
38
+
39
+ // Initialize wobble offsets if needed
40
+ val state = BreathingSharedState
41
+ if (state.wobbleOffsets.size != config.pointCount) {
42
+ state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
43
+ state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
44
+ }
45
+
46
+ // Update animation state
47
+ val animationState = updateAnimationState(frameTime, config)
48
+
49
+ Box(
50
+ modifier = modifier
51
+ .aspectRatio(1f),
52
+ contentAlignment = Alignment.Center
53
+ ) {
54
+ // Progress ring (behind blob)
55
+ if (config.showProgressRing) {
56
+ ProgressRingView(
57
+ progress = animationState.phaseProgress,
58
+ color = config.progressRingColor,
59
+ lineWidth = 8f,
60
+ modifier = Modifier
61
+ .fillMaxSize(0.95f)
62
+ )
63
+ }
64
+
65
+ // Main morphing blob with shadow
66
+ Box(
67
+ modifier = Modifier
68
+ .fillMaxSize()
69
+ .then(
70
+ if (config.showShadow) {
71
+ Modifier.drawBehind {
72
+ // Simple shadow approximation
73
+ drawCircle(
74
+ color = config.blobColors.firstOrNull()?.copy(alpha = 0.3f) ?: Color.Transparent,
75
+ radius = size.minDimension / 2 * animationState.scale.toFloat() * 0.7f,
76
+ center = center.copy(y = center.y + 8.dp.toPx())
77
+ )
78
+ }
79
+ } else Modifier
80
+ ),
81
+ contentAlignment = Alignment.Center
82
+ ) {
83
+ MorphingBlobView(
84
+ baseRadius = animationState.scale.toFloat() * 0.7f,
85
+ pointCount = config.pointCount,
86
+ offsets = animationState.wobbleOffsets,
87
+ colors = config.blobColors,
88
+ innerColor = config.innerBlobColor,
89
+ showInnerBlob = config.showInnerBlob,
90
+ modifier = Modifier.fillMaxSize()
91
+ )
92
+ }
93
+
94
+ // Text cue
95
+ if (config.showTextCue) {
96
+ BreathingTextCue(
97
+ text = animationState.label,
98
+ color = config.textColor
99
+ )
100
+ }
101
+ }
102
+ }
103
+
104
+ private data class AnimationState(
105
+ val scale: Double,
106
+ val wobbleOffsets: List<Double>,
107
+ val phaseProgress: Double,
108
+ val label: String
109
+ )
110
+
111
+ private fun updateAnimationState(
112
+ frameTime: Long,
113
+ config: BreathingConfiguration
114
+ ): AnimationState {
115
+ val state = BreathingSharedState
116
+
117
+ // Handle different states
118
+ return when (state.state) {
119
+ BreathingExerciseState.STOPPED, BreathingExerciseState.COMPLETE -> {
120
+ AnimationState(
121
+ scale = 1.0,
122
+ wobbleOffsets = List(config.pointCount) { 0.0 },
123
+ phaseProgress = 0.0,
124
+ label = ""
125
+ )
126
+ }
127
+
128
+ BreathingExerciseState.PAUSED -> {
129
+ AnimationState(
130
+ scale = state.currentScale,
131
+ wobbleOffsets = state.wobbleOffsets.toList(),
132
+ phaseProgress = state.phaseProgress,
133
+ label = state.currentLabel
134
+ )
135
+ }
136
+
137
+ BreathingExerciseState.RUNNING -> {
138
+ // Update phase timing
139
+ updatePhaseState(frameTime)
140
+
141
+ // Interpolate scale toward target
142
+ val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
143
+ state.lastWobbleUpdate = frameTime
144
+
145
+ // Smooth scale interpolation
146
+ val scaleFactor = min(1.0, dt * 3.0)
147
+ state.currentScale = state.currentScale + (state.targetScale - state.currentScale) * scaleFactor
148
+
149
+ // Update wobble animation
150
+ updateWobble(frameTime, config)
151
+
152
+ AnimationState(
153
+ scale = state.currentScale,
154
+ wobbleOffsets = state.wobbleOffsets.toList(),
155
+ phaseProgress = state.phaseProgress,
156
+ label = state.currentLabel
157
+ )
158
+ }
159
+ }
160
+ }
161
+
162
+ private fun updatePhaseState(frameTime: Long) {
163
+ val state = BreathingSharedState
164
+ if (state.phases.isEmpty()) return
165
+
166
+ val currentPhaseConfig = state.phases[state.currentPhaseIndex]
167
+ val elapsed = (frameTime - state.phaseStartTime) / 1_000_000_000.0
168
+ val duration = currentPhaseConfig.duration
169
+
170
+ // Calculate progress through current phase
171
+ state.phaseProgress = min(1.0, elapsed / duration)
172
+
173
+ // Check if phase is complete
174
+ if (elapsed >= duration) {
175
+ // Move to next phase
176
+ state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.size
177
+
178
+ // Check if we completed a cycle
179
+ if (state.currentPhaseIndex == 0) {
180
+ state.currentCycle += 1
181
+
182
+ // Check if exercise is complete
183
+ val totalCycles = state.totalCycles
184
+ if (totalCycles != null && state.currentCycle >= totalCycles) {
185
+ state.state = BreathingExerciseState.COMPLETE
186
+ state.totalDuration = (frameTime - state.exerciseStartTime) / 1_000_000_000.0
187
+ state.onExerciseComplete?.invoke(state.currentCycle, state.totalDuration)
188
+ return
189
+ }
190
+ }
191
+
192
+ // Start new phase
193
+ state.phaseStartTime = frameTime
194
+ val newPhaseConfig = state.phases[state.currentPhaseIndex]
195
+ state.currentPhase = newPhaseConfig.phase
196
+ state.currentLabel = newPhaseConfig.label
197
+ state.targetScale = newPhaseConfig.targetScale
198
+ state.phaseProgress = 0.0
199
+
200
+ // Update wobble intensity based on phase
201
+ state.wobbleIntensity = when (newPhaseConfig.phase) {
202
+ BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
203
+ BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
204
+ BreathPhase.IDLE -> 0.5
205
+ }
206
+
207
+ // Fire phase change callback
208
+ state.onPhaseChange?.invoke(
209
+ newPhaseConfig.phase,
210
+ newPhaseConfig.label,
211
+ state.currentPhaseIndex,
212
+ state.currentCycle
213
+ )
214
+ }
215
+ }
216
+
217
+ private fun updateWobble(frameTime: Long, config: BreathingConfiguration) {
218
+ val state = BreathingSharedState
219
+ val wobbleUpdateInterval = 0.8 // New targets every 0.8 seconds
220
+
221
+ // Ensure arrays are sized correctly
222
+ if (state.wobbleOffsets.size != config.pointCount) {
223
+ state.wobbleOffsets = MutableList(config.pointCount) { 0.0 }
224
+ state.wobbleTargets = MutableList(config.pointCount) { 0.0 }
225
+ }
226
+
227
+ // Check if we need new random targets
228
+ val timeSinceLastTarget = (frameTime - state.lastWobbleTargetUpdate) / 1_000_000_000.0
229
+ if (timeSinceLastTarget > wobbleUpdateInterval || state.wobbleTargets.all { it == 0.0 }) {
230
+ state.lastWobbleTargetUpdate = frameTime
231
+ val intensity = state.wobbleIntensity * config.wobbleIntensity
232
+ val maxOffset = 0.08 * intensity // Max 8% radius variation at full intensity
233
+
234
+ for (i in 0 until config.pointCount) {
235
+ state.wobbleTargets[i] = Random.nextDouble(-maxOffset, maxOffset)
236
+ }
237
+ }
238
+
239
+ // Interpolate offsets toward targets
240
+ val interpolationSpeed = 4.0
241
+ val dt = (frameTime - state.lastWobbleUpdate) / 1_000_000_000.0
242
+ val factor = min(1.0, dt * interpolationSpeed)
243
+
244
+ for (i in 0 until min(state.wobbleOffsets.size, state.wobbleTargets.size)) {
245
+ state.wobbleOffsets[i] = state.wobbleOffsets[i] + (state.wobbleTargets[i] - state.wobbleOffsets[i]) * factor
246
+ }
247
+ }
@@ -0,0 +1,104 @@
1
+ package expo.modules.breathing
2
+
3
+ enum class BreathPhase(val value: String) {
4
+ INHALE("inhale"),
5
+ HOLD_IN("holdIn"),
6
+ EXHALE("exhale"),
7
+ HOLD_OUT("holdOut"),
8
+ IDLE("idle")
9
+ }
10
+
11
+ enum class BreathingExerciseState {
12
+ STOPPED,
13
+ RUNNING,
14
+ PAUSED,
15
+ COMPLETE
16
+ }
17
+
18
+ data class BreathPhaseConfig(
19
+ val phase: BreathPhase,
20
+ val duration: Double, // seconds
21
+ val targetScale: Double,
22
+ val label: String
23
+ )
24
+
25
+ object BreathingSharedState {
26
+ // Pattern configuration
27
+ @Volatile
28
+ var phases: List<BreathPhaseConfig> = emptyList()
29
+
30
+ @Volatile
31
+ var totalCycles: Int? = null // null = infinite
32
+
33
+ // Current state
34
+ @Volatile
35
+ var state: BreathingExerciseState = BreathingExerciseState.STOPPED
36
+
37
+ @Volatile
38
+ var currentPhaseIndex: Int = 0
39
+
40
+ @Volatile
41
+ var currentCycle: Int = 0
42
+
43
+ @Volatile
44
+ var phaseStartTime: Long = System.nanoTime()
45
+
46
+ @Volatile
47
+ var pauseTime: Long? = null
48
+
49
+ // Animation values
50
+ @Volatile
51
+ var currentScale: Double = 1.0
52
+
53
+ @Volatile
54
+ var targetScale: Double = 1.0
55
+
56
+ @Volatile
57
+ var currentPhase: BreathPhase = BreathPhase.IDLE
58
+
59
+ @Volatile
60
+ var currentLabel: String = ""
61
+
62
+ @Volatile
63
+ var phaseProgress: Double = 0.0
64
+
65
+ // Wobble animation
66
+ @Volatile
67
+ var wobbleIntensity: Double = 1.0
68
+
69
+ @Volatile
70
+ var wobbleOffsets: MutableList<Double> = mutableListOf()
71
+
72
+ @Volatile
73
+ var wobbleTargets: MutableList<Double> = mutableListOf()
74
+
75
+ @Volatile
76
+ var lastWobbleUpdate: Long = System.nanoTime()
77
+
78
+ @Volatile
79
+ var lastWobbleTargetUpdate: Long = System.nanoTime()
80
+
81
+ // Exercise tracking
82
+ @Volatile
83
+ var exerciseStartTime: Long = System.nanoTime()
84
+
85
+ @Volatile
86
+ var totalDuration: Double = 0.0
87
+
88
+ // Callbacks
89
+ var onPhaseChange: ((BreathPhase, String, Int, Int) -> Unit)? = null
90
+ var onExerciseComplete: ((Int, Double) -> Unit)? = null
91
+
92
+ fun reset() {
93
+ state = BreathingExerciseState.STOPPED
94
+ currentPhaseIndex = 0
95
+ currentCycle = 0
96
+ currentScale = 1.0
97
+ targetScale = 1.0
98
+ currentPhase = BreathPhase.IDLE
99
+ currentLabel = ""
100
+ phaseProgress = 0.0
101
+ wobbleIntensity = 1.0
102
+ pauseTime = null
103
+ }
104
+ }
@@ -0,0 +1,41 @@
1
+ package expo.modules.breathing
2
+
3
+ import androidx.compose.animation.AnimatedContent
4
+ import androidx.compose.animation.fadeIn
5
+ import androidx.compose.animation.fadeOut
6
+ import androidx.compose.animation.togetherWith
7
+ import androidx.compose.foundation.layout.Box
8
+ import androidx.compose.material3.Text
9
+ import androidx.compose.runtime.Composable
10
+ import androidx.compose.ui.Alignment
11
+ import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.graphics.Color
13
+ import androidx.compose.ui.text.font.FontWeight
14
+ import androidx.compose.ui.unit.sp
15
+
16
+ @Composable
17
+ fun BreathingTextCue(
18
+ text: String,
19
+ color: Color,
20
+ modifier: Modifier = Modifier
21
+ ) {
22
+ Box(
23
+ modifier = modifier,
24
+ contentAlignment = Alignment.Center
25
+ ) {
26
+ AnimatedContent(
27
+ targetState = text,
28
+ transitionSpec = {
29
+ fadeIn() togetherWith fadeOut()
30
+ },
31
+ label = "BreathingTextCue"
32
+ ) { currentText ->
33
+ Text(
34
+ text = currentText,
35
+ color = color,
36
+ fontSize = 24.sp,
37
+ fontWeight = FontWeight.Medium
38
+ )
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,156 @@
1
+ package expo.modules.breathing
2
+
3
+ import android.graphics.Color as AndroidColor
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ class ExpoBreathingExerciseModule : Module() {
8
+ override fun definition() = ModuleDefinition {
9
+ Name("ExpoBreathingExercise")
10
+
11
+ Events("onPhaseChange", "onExerciseComplete")
12
+
13
+ Function("startBreathingExercise") { pattern: Map<String, Any?> ->
14
+ startExercise(pattern)
15
+ }
16
+
17
+ Function("stopBreathingExercise") {
18
+ BreathingSharedState.reset()
19
+ }
20
+
21
+ Function("pauseBreathingExercise") {
22
+ val state = BreathingSharedState
23
+ if (state.state == BreathingExerciseState.RUNNING) {
24
+ state.state = BreathingExerciseState.PAUSED
25
+ state.pauseTime = System.nanoTime()
26
+ }
27
+ }
28
+
29
+ Function("resumeBreathingExercise") {
30
+ val state = BreathingSharedState
31
+ val pauseTime = state.pauseTime
32
+ if (state.state == BreathingExerciseState.PAUSED && pauseTime != null) {
33
+ val pauseDuration = System.nanoTime() - pauseTime
34
+ state.phaseStartTime += pauseDuration
35
+ state.exerciseStartTime += pauseDuration
36
+ state.state = BreathingExerciseState.RUNNING
37
+ state.pauseTime = null
38
+ }
39
+ }
40
+
41
+ View(ExpoBreathingExerciseView::class) {
42
+ Events("onPhaseChange", "onExerciseComplete")
43
+
44
+ Prop("blobColors") { view: ExpoBreathingExerciseView, colors: List<Any> ->
45
+ val parsedColors = colors.map { parseColor(it) }
46
+ view.setBlobColors(parsedColors)
47
+ }
48
+
49
+ Prop("innerBlobColor") { view: ExpoBreathingExerciseView, color: Any ->
50
+ view.setInnerBlobColor(parseColor(color))
51
+ }
52
+
53
+ Prop("progressRingColor") { view: ExpoBreathingExerciseView, color: Any ->
54
+ view.setProgressRingColor(parseColor(color))
55
+ }
56
+
57
+ Prop("textColor") { view: ExpoBreathingExerciseView, color: Any ->
58
+ view.setTextColor(parseColor(color))
59
+ }
60
+
61
+ Prop("showProgressRing") { view: ExpoBreathingExerciseView, value: Boolean ->
62
+ view.setShowProgressRing(value)
63
+ }
64
+
65
+ Prop("showTextCue") { view: ExpoBreathingExerciseView, value: Boolean ->
66
+ view.setShowTextCue(value)
67
+ }
68
+
69
+ Prop("showInnerBlob") { view: ExpoBreathingExerciseView, value: Boolean ->
70
+ view.setShowInnerBlob(value)
71
+ }
72
+
73
+ Prop("showShadow") { view: ExpoBreathingExerciseView, value: Boolean ->
74
+ view.setShowShadow(value)
75
+ }
76
+
77
+ Prop("pointCount") { view: ExpoBreathingExerciseView, value: Int ->
78
+ view.setPointCount(value)
79
+ }
80
+
81
+ Prop("wobbleIntensity") { view: ExpoBreathingExerciseView, value: Double ->
82
+ view.setWobbleIntensity(value)
83
+ }
84
+ }
85
+ }
86
+
87
+ private fun startExercise(pattern: Map<String, Any?>) {
88
+ val state = BreathingSharedState
89
+ state.reset()
90
+
91
+ // Parse phases
92
+ @Suppress("UNCHECKED_CAST")
93
+ val phasesArray = pattern["phases"] as? List<Map<String, Any?>> ?: return
94
+
95
+ val phases = phasesArray.mapNotNull { phaseDict ->
96
+ val phaseString = phaseDict["phase"] as? String ?: return@mapNotNull null
97
+ val duration = (phaseDict["duration"] as? Number)?.toDouble() ?: return@mapNotNull null
98
+ val targetScale = (phaseDict["targetScale"] as? Number)?.toDouble() ?: return@mapNotNull null
99
+ val label = phaseDict["label"] as? String ?: return@mapNotNull null
100
+
101
+ val phase = when (phaseString) {
102
+ "inhale" -> BreathPhase.INHALE
103
+ "holdIn" -> BreathPhase.HOLD_IN
104
+ "exhale" -> BreathPhase.EXHALE
105
+ "holdOut" -> BreathPhase.HOLD_OUT
106
+ else -> return@mapNotNull null
107
+ }
108
+
109
+ BreathPhaseConfig(
110
+ phase = phase,
111
+ duration = duration / 1000.0, // Convert ms to seconds
112
+ targetScale = targetScale,
113
+ label = label
114
+ )
115
+ }
116
+
117
+ if (phases.isEmpty()) return
118
+
119
+ // Parse cycles
120
+ state.totalCycles = (pattern["cycles"] as? Number)?.toInt()
121
+
122
+ // Setup state
123
+ state.phases = phases
124
+ state.currentPhaseIndex = 0
125
+ state.currentCycle = 0
126
+ state.phaseStartTime = System.nanoTime()
127
+ state.exerciseStartTime = System.nanoTime()
128
+
129
+ // Set initial phase values
130
+ val firstPhase = phases[0]
131
+ state.currentPhase = firstPhase.phase
132
+ state.currentLabel = firstPhase.label
133
+ state.targetScale = firstPhase.targetScale
134
+ state.currentScale = 1.0
135
+ state.phaseProgress = 0.0
136
+
137
+ // Set initial wobble intensity
138
+ state.wobbleIntensity = when (firstPhase.phase) {
139
+ BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
140
+ BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
141
+ BreathPhase.IDLE -> 0.5
142
+ }
143
+
144
+ state.state = BreathingExerciseState.RUNNING
145
+ }
146
+
147
+ private fun parseColor(value: Any): Int {
148
+ return when (value) {
149
+ is String -> AndroidColor.parseColor(value)
150
+ is Int -> value
151
+ is Double -> value.toInt()
152
+ is Long -> value.toInt()
153
+ else -> AndroidColor.WHITE
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,123 @@
1
+ package expo.modules.breathing
2
+
3
+ import android.content.Context
4
+ import androidx.compose.foundation.layout.Box
5
+ import androidx.compose.foundation.layout.fillMaxSize
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.runtime.getValue
9
+ import androidx.compose.runtime.mutableStateOf
10
+ import androidx.compose.runtime.setValue
11
+ import androidx.compose.ui.Alignment
12
+ import androidx.compose.ui.Modifier
13
+ import androidx.compose.ui.graphics.Color
14
+ import androidx.compose.ui.platform.ComposeView
15
+ import androidx.compose.ui.unit.dp
16
+ import expo.modules.kotlin.AppContext
17
+ import expo.modules.kotlin.views.ExpoView
18
+
19
+ class ExpoBreathingExerciseView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
20
+
21
+ // Mutable state for configuration
22
+ private var config by mutableStateOf(BreathingConfiguration())
23
+
24
+ private val onPhaseChange by EventDispatcher()
25
+ private val onExerciseComplete by EventDispatcher()
26
+
27
+ private val composeView = ComposeView(context).apply {
28
+ setContent {
29
+ BreathingExerciseViewContent()
30
+ }
31
+ }
32
+
33
+ init {
34
+ // Allow effects to render outside view bounds
35
+ clipChildren = false
36
+ clipToPadding = false
37
+ composeView.clipChildren = false
38
+ composeView.clipToPadding = false
39
+ addView(composeView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
40
+
41
+ // Setup callbacks
42
+ setupCallbacks()
43
+ }
44
+
45
+ private fun setupCallbacks() {
46
+ BreathingSharedState.onPhaseChange = { phase, label, phaseIndex, cycle ->
47
+ onPhaseChange(
48
+ mapOf(
49
+ "phase" to phase.value,
50
+ "label" to label,
51
+ "phaseIndex" to phaseIndex,
52
+ "cycle" to cycle
53
+ )
54
+ )
55
+ }
56
+
57
+ BreathingSharedState.onExerciseComplete = { totalCycles, totalDuration ->
58
+ onExerciseComplete(
59
+ mapOf(
60
+ "totalCycles" to totalCycles,
61
+ "totalDuration" to totalDuration
62
+ )
63
+ )
64
+ }
65
+ }
66
+
67
+ @Composable
68
+ private fun BreathingExerciseViewContent() {
69
+ Box(
70
+ modifier = Modifier
71
+ .fillMaxSize()
72
+ .padding(24.dp),
73
+ contentAlignment = Alignment.Center
74
+ ) {
75
+ BreathingExerciseView(
76
+ config = config,
77
+ modifier = Modifier.fillMaxSize()
78
+ )
79
+ }
80
+ }
81
+
82
+ fun setBlobColors(colors: List<Int>) {
83
+ config = config.copy(
84
+ blobColors = colors.map { Color(it) }
85
+ )
86
+ }
87
+
88
+ fun setInnerBlobColor(color: Int) {
89
+ config = config.copy(innerBlobColor = Color(color))
90
+ }
91
+
92
+ fun setProgressRingColor(color: Int) {
93
+ config = config.copy(progressRingColor = Color(color))
94
+ }
95
+
96
+ fun setTextColor(color: Int) {
97
+ config = config.copy(textColor = Color(color))
98
+ }
99
+
100
+ fun setShowProgressRing(value: Boolean) {
101
+ config = config.copy(showProgressRing = value)
102
+ }
103
+
104
+ fun setShowTextCue(value: Boolean) {
105
+ config = config.copy(showTextCue = value)
106
+ }
107
+
108
+ fun setShowInnerBlob(value: Boolean) {
109
+ config = config.copy(showInnerBlob = value)
110
+ }
111
+
112
+ fun setShowShadow(value: Boolean) {
113
+ config = config.copy(showShadow = value)
114
+ }
115
+
116
+ fun setPointCount(value: Int) {
117
+ config = config.copy(pointCount = maxOf(6, value))
118
+ }
119
+
120
+ fun setWobbleIntensity(value: Double) {
121
+ config = config.copy(wobbleIntensity = value.coerceIn(0.0, 1.0))
122
+ }
123
+ }