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,51 @@
|
|
|
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.geometry.Offset
|
|
13
|
+
import androidx.compose.ui.graphics.Color
|
|
14
|
+
import androidx.compose.ui.graphics.Shadow
|
|
15
|
+
import androidx.compose.ui.text.TextStyle
|
|
16
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
17
|
+
import androidx.compose.ui.unit.sp
|
|
18
|
+
|
|
19
|
+
@Composable
|
|
20
|
+
fun BreathingTextCue(
|
|
21
|
+
text: String,
|
|
22
|
+
color: Color,
|
|
23
|
+
modifier: Modifier = Modifier
|
|
24
|
+
) {
|
|
25
|
+
Box(
|
|
26
|
+
modifier = modifier,
|
|
27
|
+
contentAlignment = Alignment.Center
|
|
28
|
+
) {
|
|
29
|
+
AnimatedContent(
|
|
30
|
+
targetState = text,
|
|
31
|
+
transitionSpec = {
|
|
32
|
+
fadeIn() togetherWith fadeOut()
|
|
33
|
+
},
|
|
34
|
+
label = "BreathingTextCue"
|
|
35
|
+
) { currentText ->
|
|
36
|
+
Text(
|
|
37
|
+
text = currentText,
|
|
38
|
+
style = TextStyle(
|
|
39
|
+
color = color,
|
|
40
|
+
fontSize = 24.sp,
|
|
41
|
+
fontWeight = FontWeight.SemiBold,
|
|
42
|
+
shadow = Shadow(
|
|
43
|
+
color = Color.Black.copy(alpha = 0.5f),
|
|
44
|
+
offset = Offset(0f, 2f),
|
|
45
|
+
blurRadius = 8f
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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("showParticles") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
78
|
+
view.setShowParticles(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Prop("showWavyBlobs") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
82
|
+
view.setShowWavyBlobs(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Prop("showGlowEffects") { view: ExpoBreathingExerciseView, value: Boolean ->
|
|
86
|
+
view.setShowGlowEffects(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Prop("glowColor") { view: ExpoBreathingExerciseView, color: Any ->
|
|
90
|
+
view.setGlowColor(parseColor(color))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
Prop("particleColor") { view: ExpoBreathingExerciseView, color: Any ->
|
|
94
|
+
view.setParticleColor(parseColor(color))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
Prop("pointCount") { view: ExpoBreathingExerciseView, value: Int ->
|
|
98
|
+
view.setPointCount(value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Prop("wobbleIntensity") { view: ExpoBreathingExerciseView, value: Double ->
|
|
102
|
+
view.setWobbleIntensity(value)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun startExercise(pattern: Map<String, Any?>) {
|
|
108
|
+
val state = BreathingSharedState
|
|
109
|
+
state.reset()
|
|
110
|
+
|
|
111
|
+
// Parse phases
|
|
112
|
+
@Suppress("UNCHECKED_CAST")
|
|
113
|
+
val phasesArray = pattern["phases"] as? List<Map<String, Any?>> ?: return
|
|
114
|
+
|
|
115
|
+
val phases = phasesArray.mapNotNull { phaseDict ->
|
|
116
|
+
val phaseString = phaseDict["phase"] as? String ?: return@mapNotNull null
|
|
117
|
+
val duration = (phaseDict["duration"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
118
|
+
val targetScale = (phaseDict["targetScale"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
119
|
+
val label = phaseDict["label"] as? String ?: return@mapNotNull null
|
|
120
|
+
|
|
121
|
+
val phase = when (phaseString) {
|
|
122
|
+
"inhale" -> BreathPhase.INHALE
|
|
123
|
+
"holdIn" -> BreathPhase.HOLD_IN
|
|
124
|
+
"exhale" -> BreathPhase.EXHALE
|
|
125
|
+
"holdOut" -> BreathPhase.HOLD_OUT
|
|
126
|
+
else -> return@mapNotNull null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
BreathPhaseConfig(
|
|
130
|
+
phase = phase,
|
|
131
|
+
duration = duration / 1000.0, // Convert ms to seconds
|
|
132
|
+
targetScale = targetScale,
|
|
133
|
+
label = label
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (phases.isEmpty()) return
|
|
138
|
+
|
|
139
|
+
// Parse cycles
|
|
140
|
+
state.totalCycles = (pattern["cycles"] as? Number)?.toInt()
|
|
141
|
+
|
|
142
|
+
// Setup state
|
|
143
|
+
state.phases = phases
|
|
144
|
+
state.currentPhaseIndex = 0
|
|
145
|
+
state.currentCycle = 0
|
|
146
|
+
state.phaseStartTime = System.nanoTime()
|
|
147
|
+
state.exerciseStartTime = System.nanoTime()
|
|
148
|
+
|
|
149
|
+
// Set initial phase values
|
|
150
|
+
val firstPhase = phases[0]
|
|
151
|
+
state.currentPhase = firstPhase.phase
|
|
152
|
+
state.currentLabel = firstPhase.label
|
|
153
|
+
state.startScale = 1.0
|
|
154
|
+
state.targetScale = firstPhase.targetScale
|
|
155
|
+
state.currentScale = 1.0
|
|
156
|
+
state.phaseProgress = 0.0
|
|
157
|
+
|
|
158
|
+
// Set initial wobble intensity
|
|
159
|
+
state.wobbleIntensity = when (firstPhase.phase) {
|
|
160
|
+
BreathPhase.INHALE, BreathPhase.EXHALE -> 1.0
|
|
161
|
+
BreathPhase.HOLD_IN, BreathPhase.HOLD_OUT -> 0.3
|
|
162
|
+
BreathPhase.IDLE -> 0.5
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
state.state = BreathingExerciseState.RUNNING
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun parseColor(value: Any): Int {
|
|
169
|
+
return when (value) {
|
|
170
|
+
is String -> AndroidColor.parseColor(value)
|
|
171
|
+
is Int -> value
|
|
172
|
+
is Double -> value.toInt()
|
|
173
|
+
is Long -> value.toInt()
|
|
174
|
+
else -> AndroidColor.WHITE
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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.viewevent.EventDispatcher
|
|
18
|
+
import expo.modules.kotlin.views.ExpoView
|
|
19
|
+
|
|
20
|
+
class ExpoBreathingExerciseView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
21
|
+
|
|
22
|
+
// Mutable state for configuration
|
|
23
|
+
private var config by mutableStateOf(BreathingConfiguration())
|
|
24
|
+
|
|
25
|
+
private val onPhaseChange by EventDispatcher()
|
|
26
|
+
private val onExerciseComplete by EventDispatcher()
|
|
27
|
+
|
|
28
|
+
private val composeView = ComposeView(context).apply {
|
|
29
|
+
setContent {
|
|
30
|
+
BreathingExerciseViewContent()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
init {
|
|
35
|
+
// Allow effects to render outside view bounds
|
|
36
|
+
clipChildren = false
|
|
37
|
+
clipToPadding = false
|
|
38
|
+
composeView.clipChildren = false
|
|
39
|
+
composeView.clipToPadding = false
|
|
40
|
+
addView(composeView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
41
|
+
|
|
42
|
+
// Setup callbacks
|
|
43
|
+
setupCallbacks()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private fun setupCallbacks() {
|
|
47
|
+
BreathingSharedState.onPhaseChange = { phase, label, phaseIndex, cycle ->
|
|
48
|
+
onPhaseChange(
|
|
49
|
+
mapOf(
|
|
50
|
+
"phase" to phase.value,
|
|
51
|
+
"label" to label,
|
|
52
|
+
"phaseIndex" to phaseIndex,
|
|
53
|
+
"cycle" to cycle
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
BreathingSharedState.onExerciseComplete = { totalCycles, totalDuration ->
|
|
59
|
+
onExerciseComplete(
|
|
60
|
+
mapOf(
|
|
61
|
+
"totalCycles" to totalCycles,
|
|
62
|
+
"totalDuration" to totalDuration
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Composable
|
|
69
|
+
private fun BreathingExerciseViewContent() {
|
|
70
|
+
Box(
|
|
71
|
+
modifier = Modifier
|
|
72
|
+
.fillMaxSize()
|
|
73
|
+
.padding(24.dp),
|
|
74
|
+
contentAlignment = Alignment.Center
|
|
75
|
+
) {
|
|
76
|
+
BreathingExerciseView(
|
|
77
|
+
config = config,
|
|
78
|
+
modifier = Modifier.fillMaxSize()
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun setBlobColors(colors: List<Int>) {
|
|
84
|
+
config = config.copy(
|
|
85
|
+
blobColors = colors.map { Color(it) }
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun setInnerBlobColor(color: Int) {
|
|
90
|
+
config = config.copy(innerBlobColor = Color(color))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fun setProgressRingColor(color: Int) {
|
|
94
|
+
config = config.copy(progressRingColor = Color(color))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun setTextColor(color: Int) {
|
|
98
|
+
config = config.copy(textColor = Color(color))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fun setShowProgressRing(value: Boolean) {
|
|
102
|
+
config = config.copy(showProgressRing = value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fun setShowTextCue(value: Boolean) {
|
|
106
|
+
config = config.copy(showTextCue = value)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun setShowInnerBlob(value: Boolean) {
|
|
110
|
+
config = config.copy(showInnerBlob = value)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fun setShowShadow(value: Boolean) {
|
|
114
|
+
config = config.copy(showShadow = value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fun setPointCount(value: Int) {
|
|
118
|
+
config = config.copy(pointCount = maxOf(6, value))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fun setWobbleIntensity(value: Double) {
|
|
122
|
+
config = config.copy(wobbleIntensity = value.coerceIn(0.0, 1.0))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fun setGlowColor(color: Int) {
|
|
126
|
+
config = config.copy(glowColor = Color(color))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
fun setParticleColor(color: Int) {
|
|
130
|
+
config = config.copy(particleColor = Color(color))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun setShowParticles(value: Boolean) {
|
|
134
|
+
config = config.copy(showParticles = value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun setShowWavyBlobs(value: Boolean) {
|
|
138
|
+
config = config.copy(showWavyBlobs = value)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fun setShowGlowEffects(value: Boolean) {
|
|
142
|
+
config = config.copy(showGlowEffects = value)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.Canvas
|
|
4
|
+
import androidx.compose.runtime.Composable
|
|
5
|
+
import androidx.compose.ui.Modifier
|
|
6
|
+
import androidx.compose.ui.geometry.Offset
|
|
7
|
+
import androidx.compose.ui.graphics.Brush
|
|
8
|
+
import androidx.compose.ui.graphics.Color
|
|
9
|
+
import androidx.compose.ui.graphics.Path
|
|
10
|
+
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
11
|
+
import kotlin.math.PI
|
|
12
|
+
import kotlin.math.cos
|
|
13
|
+
import kotlin.math.min
|
|
14
|
+
import kotlin.math.sin
|
|
15
|
+
import kotlin.math.tan
|
|
16
|
+
|
|
17
|
+
@Composable
|
|
18
|
+
fun MorphingBlobView(
|
|
19
|
+
baseRadius: Float,
|
|
20
|
+
pointCount: Int,
|
|
21
|
+
offsets: List<Double>,
|
|
22
|
+
colors: List<Color>,
|
|
23
|
+
innerColor: Color,
|
|
24
|
+
showInnerBlob: Boolean,
|
|
25
|
+
modifier: Modifier = Modifier
|
|
26
|
+
) {
|
|
27
|
+
Canvas(modifier = modifier) {
|
|
28
|
+
val size = min(this.size.width, this.size.height)
|
|
29
|
+
val center = Offset(this.size.width / 2f, this.size.height / 2f)
|
|
30
|
+
val effectiveRadius = baseRadius * size / 2f
|
|
31
|
+
|
|
32
|
+
// Draw main blob
|
|
33
|
+
val mainPath = createBlobPath(center, effectiveRadius, pointCount, offsets)
|
|
34
|
+
drawPath(
|
|
35
|
+
path = mainPath,
|
|
36
|
+
brush = Brush.radialGradient(
|
|
37
|
+
colors = colors,
|
|
38
|
+
center = center,
|
|
39
|
+
radius = effectiveRadius
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Draw inner blob for depth
|
|
44
|
+
if (showInnerBlob) {
|
|
45
|
+
val innerPath = createBlobPath(
|
|
46
|
+
center = center,
|
|
47
|
+
baseRadius = effectiveRadius * 0.6f,
|
|
48
|
+
pointCount = pointCount,
|
|
49
|
+
offsets = offsets.map { it * 0.7 }
|
|
50
|
+
)
|
|
51
|
+
drawPath(
|
|
52
|
+
path = innerPath,
|
|
53
|
+
color = innerColor
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun DrawScope.createBlobPath(
|
|
60
|
+
center: Offset,
|
|
61
|
+
baseRadius: Float,
|
|
62
|
+
pointCount: Int,
|
|
63
|
+
offsets: List<Double>
|
|
64
|
+
): Path {
|
|
65
|
+
val path = Path()
|
|
66
|
+
if (pointCount < 3) return path
|
|
67
|
+
|
|
68
|
+
// Generate points around the circle with offsets
|
|
69
|
+
val points = mutableListOf<Offset>()
|
|
70
|
+
for (i in 0 until pointCount) {
|
|
71
|
+
val angle = (i.toDouble() / pointCount) * 2 * PI - PI / 2
|
|
72
|
+
val offset = if (i < offsets.size) offsets[i] else 0.0
|
|
73
|
+
val radius = baseRadius * (1.0 + offset).toFloat()
|
|
74
|
+
|
|
75
|
+
points.add(
|
|
76
|
+
Offset(
|
|
77
|
+
x = center.x + (cos(angle) * radius).toFloat(),
|
|
78
|
+
y = center.y + (sin(angle) * radius).toFloat()
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Tangent coefficient for smooth cubic bezier curves
|
|
84
|
+
val tangentCoeff = ((4.0 / 3.0) * tan(PI / (2.0 * pointCount))).toFloat()
|
|
85
|
+
|
|
86
|
+
// Start at first point
|
|
87
|
+
path.moveTo(points[0].x, points[0].y)
|
|
88
|
+
|
|
89
|
+
// Draw cubic bezier curves between each pair of points
|
|
90
|
+
for (i in 0 until pointCount) {
|
|
91
|
+
val current = points[i]
|
|
92
|
+
val next = points[(i + 1) % pointCount]
|
|
93
|
+
|
|
94
|
+
// Calculate angles for tangent directions
|
|
95
|
+
val currentAngle = (i.toDouble() / pointCount) * 2 * PI - PI / 2
|
|
96
|
+
val nextAngle = (((i + 1) % pointCount).toDouble() / pointCount) * 2 * PI - PI / 2
|
|
97
|
+
|
|
98
|
+
// Current point's radius and tangent
|
|
99
|
+
val currentOffset = if (i < offsets.size) offsets[i] else 0.0
|
|
100
|
+
val currentRadius = baseRadius * (1.0 + currentOffset).toFloat()
|
|
101
|
+
val currentTangentLength = currentRadius * tangentCoeff
|
|
102
|
+
|
|
103
|
+
// Next point's radius and tangent
|
|
104
|
+
val nextOffset = if ((i + 1) % pointCount < offsets.size) offsets[(i + 1) % pointCount] else 0.0
|
|
105
|
+
val nextRadius = baseRadius * (1.0 + nextOffset).toFloat()
|
|
106
|
+
val nextTangentLength = nextRadius * tangentCoeff
|
|
107
|
+
|
|
108
|
+
// Control points are perpendicular to radial direction
|
|
109
|
+
val control1 = Offset(
|
|
110
|
+
x = current.x + (cos(currentAngle + PI / 2) * currentTangentLength).toFloat(),
|
|
111
|
+
y = current.y + (sin(currentAngle + PI / 2) * currentTangentLength).toFloat()
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
val control2 = Offset(
|
|
115
|
+
x = next.x + (cos(nextAngle - PI / 2) * nextTangentLength).toFloat(),
|
|
116
|
+
y = next.y + (sin(nextAngle - PI / 2) * nextTangentLength).toFloat()
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
path.cubicTo(
|
|
120
|
+
control1.x, control1.y,
|
|
121
|
+
control2.x, control2.y,
|
|
122
|
+
next.x, next.y
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
path.close()
|
|
127
|
+
return path
|
|
128
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package expo.modules.breathing
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.Canvas
|
|
4
|
+
import androidx.compose.runtime.Composable
|
|
5
|
+
import androidx.compose.ui.Modifier
|
|
6
|
+
import androidx.compose.ui.geometry.Offset
|
|
7
|
+
import androidx.compose.ui.geometry.Size
|
|
8
|
+
import androidx.compose.ui.graphics.Color
|
|
9
|
+
import androidx.compose.ui.graphics.StrokeCap
|
|
10
|
+
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
11
|
+
import kotlin.math.min
|
|
12
|
+
|
|
13
|
+
@Composable
|
|
14
|
+
fun ProgressRingView(
|
|
15
|
+
progress: Double,
|
|
16
|
+
color: Color,
|
|
17
|
+
lineWidth: Float,
|
|
18
|
+
modifier: Modifier = Modifier
|
|
19
|
+
) {
|
|
20
|
+
Canvas(modifier = modifier) {
|
|
21
|
+
val size = min(this.size.width, this.size.height)
|
|
22
|
+
val radius = (size - lineWidth) / 2f
|
|
23
|
+
val center = Offset(this.size.width / 2f, this.size.height / 2f)
|
|
24
|
+
val topLeft = Offset(center.x - radius, center.y - radius)
|
|
25
|
+
val arcSize = Size(radius * 2, radius * 2)
|
|
26
|
+
|
|
27
|
+
// Background ring
|
|
28
|
+
drawArc(
|
|
29
|
+
color = color.copy(alpha = 0.2f),
|
|
30
|
+
startAngle = 0f,
|
|
31
|
+
sweepAngle = 360f,
|
|
32
|
+
useCenter = false,
|
|
33
|
+
topLeft = topLeft,
|
|
34
|
+
size = arcSize,
|
|
35
|
+
style = Stroke(width = lineWidth)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Progress arc
|
|
39
|
+
val clampedProgress = progress.coerceIn(0.0, 1.0).toFloat()
|
|
40
|
+
drawArc(
|
|
41
|
+
color = color,
|
|
42
|
+
startAngle = -90f, // Start from top
|
|
43
|
+
sweepAngle = 360f * clampedProgress,
|
|
44
|
+
useCenter = false,
|
|
45
|
+
topLeft = topLeft,
|
|
46
|
+
size = arcSize,
|
|
47
|
+
style = Stroke(width = lineWidth, cap = StrokeCap.Round)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
export type BreathPhase = 'inhale' | 'holdIn' | 'exhale' | 'holdOut';
|
|
3
|
+
export interface BreathPhaseConfig {
|
|
4
|
+
phase: BreathPhase;
|
|
5
|
+
duration: number;
|
|
6
|
+
targetScale: number;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BreathingPattern {
|
|
10
|
+
phases: BreathPhaseConfig[];
|
|
11
|
+
cycles?: number;
|
|
12
|
+
}
|
|
13
|
+
export type BreathingPreset = 'relaxing' | 'box' | 'energizing' | 'calming';
|
|
14
|
+
export interface PhaseChangeEvent {
|
|
15
|
+
phase: BreathPhase;
|
|
16
|
+
label: string;
|
|
17
|
+
phaseIndex: number;
|
|
18
|
+
cycle: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ExerciseCompleteEvent {
|
|
21
|
+
totalCycles: number;
|
|
22
|
+
totalDuration: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ExpoBreathingExerciseViewProps {
|
|
25
|
+
blobColors?: ColorValue[];
|
|
26
|
+
innerBlobColor?: ColorValue;
|
|
27
|
+
glowColor?: ColorValue;
|
|
28
|
+
particleColor?: ColorValue;
|
|
29
|
+
progressRingColor?: ColorValue;
|
|
30
|
+
textColor?: ColorValue;
|
|
31
|
+
showProgressRing?: boolean;
|
|
32
|
+
showTextCue?: boolean;
|
|
33
|
+
showInnerBlob?: boolean;
|
|
34
|
+
showShadow?: boolean;
|
|
35
|
+
showParticles?: boolean;
|
|
36
|
+
showWavyBlobs?: boolean;
|
|
37
|
+
showGlowEffects?: boolean;
|
|
38
|
+
pointCount?: number;
|
|
39
|
+
wobbleIntensity?: number;
|
|
40
|
+
onPhaseChange?: (event: {
|
|
41
|
+
nativeEvent: PhaseChangeEvent;
|
|
42
|
+
}) => void;
|
|
43
|
+
onExerciseComplete?: (event: {
|
|
44
|
+
nativeEvent: ExerciseCompleteEvent;
|
|
45
|
+
}) => void;
|
|
46
|
+
style?: StyleProp<ViewStyle>;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=ExpoBreathingExercise.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExercise.types.d.ts","sourceRoot":"","sources":["../src/ExpoBreathingExercise.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAErE,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;AAErE,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,WAAW,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,KAAK,GAAG,YAAY,GAAG,SAAS,CAAC;AAE5E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,WAAW,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,8BAA8B;IAC7C,UAAU,CAAC,EAAE,UAAU,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,UAAU,CAAC;IAC5B,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,iBAAiB,CAAC,EAAE,UAAU,CAAC;IAC/B,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,gBAAgB,CAAA;KAAE,KAAK,IAAI,CAAC;IACnE,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,qBAAqB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExercise.types.js","sourceRoot":"","sources":["../src/ExpoBreathingExercise.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { ColorValue, StyleProp, ViewStyle } from 'react-native';\n\nexport type BreathPhase = 'inhale' | 'holdIn' | 'exhale' | 'holdOut';\n\nexport interface BreathPhaseConfig {\n phase: BreathPhase;\n duration: number; // milliseconds\n targetScale: number; // e.g., 1.0 to 1.3\n label: string; // \"Breathe In\"\n}\n\nexport interface BreathingPattern {\n phases: BreathPhaseConfig[];\n cycles?: number; // undefined = infinite\n}\n\nexport type BreathingPreset = 'relaxing' | 'box' | 'energizing' | 'calming';\n\nexport interface PhaseChangeEvent {\n phase: BreathPhase;\n label: string;\n phaseIndex: number;\n cycle: number;\n}\n\nexport interface ExerciseCompleteEvent {\n totalCycles: number;\n totalDuration: number;\n}\n\nexport interface ExpoBreathingExerciseViewProps {\n blobColors?: ColorValue[];\n innerBlobColor?: ColorValue;\n glowColor?: ColorValue;\n particleColor?: ColorValue;\n progressRingColor?: ColorValue;\n textColor?: ColorValue;\n showProgressRing?: boolean;\n showTextCue?: boolean;\n showInnerBlob?: boolean;\n showShadow?: boolean;\n showParticles?: boolean;\n showWavyBlobs?: boolean;\n showGlowEffects?: boolean;\n pointCount?: number; // Morphing points (default: 8)\n wobbleIntensity?: number; // 0-1\n onPhaseChange?: (event: { nativeEvent: PhaseChangeEvent }) => void;\n onExerciseComplete?: (event: { nativeEvent: ExerciseCompleteEvent }) => void;\n style?: StyleProp<ViewStyle>;\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
import type { BreathingPattern, BreathingPreset } from './ExpoBreathingExercise.types';
|
|
3
|
+
declare class ExpoBreathingExerciseModuleType extends NativeModule {
|
|
4
|
+
startBreathingExercise(pattern: BreathingPattern): void;
|
|
5
|
+
stopBreathingExercise(): void;
|
|
6
|
+
pauseBreathingExercise(): void;
|
|
7
|
+
resumeBreathingExercise(): void;
|
|
8
|
+
}
|
|
9
|
+
declare const module: ExpoBreathingExerciseModuleType;
|
|
10
|
+
export default module;
|
|
11
|
+
export declare function startBreathingExercise(pattern: BreathingPattern): void;
|
|
12
|
+
export declare function stopBreathingExercise(): void;
|
|
13
|
+
export declare function pauseBreathingExercise(): void;
|
|
14
|
+
export declare function resumeBreathingExercise(): void;
|
|
15
|
+
export declare function getBreathingPreset(preset: BreathingPreset): BreathingPattern;
|
|
16
|
+
//# sourceMappingURL=ExpoBreathingExerciseModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoBreathingExerciseModule.d.ts","sourceRoot":"","sources":["../src/ExpoBreathingExerciseModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AACzD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAEvF,OAAO,OAAO,+BAAgC,SAAQ,YAAY;IAChE,sBAAsB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI;IACvD,qBAAqB,IAAI,IAAI;IAC7B,sBAAsB,IAAI,IAAI;IAC9B,uBAAuB,IAAI,IAAI;CAChC;AAED,QAAA,MAAM,MAAM,iCAAgF,CAAC;AAE7F,eAAe,MAAM,CAAC;AAEtB,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAEtE;AAED,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C;AAED,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAoCD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,GAAG,gBAAgB,CAE5E"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { requireNativeModule } from 'expo';
|
|
2
|
+
const module = requireNativeModule('ExpoBreathingExercise');
|
|
3
|
+
export default module;
|
|
4
|
+
export function startBreathingExercise(pattern) {
|
|
5
|
+
module.startBreathingExercise(pattern);
|
|
6
|
+
}
|
|
7
|
+
export function stopBreathingExercise() {
|
|
8
|
+
module.stopBreathingExercise();
|
|
9
|
+
}
|
|
10
|
+
export function pauseBreathingExercise() {
|
|
11
|
+
module.pauseBreathingExercise();
|
|
12
|
+
}
|
|
13
|
+
export function resumeBreathingExercise() {
|
|
14
|
+
module.resumeBreathingExercise();
|
|
15
|
+
}
|
|
16
|
+
const BREATHING_PRESETS = {
|
|
17
|
+
relaxing: {
|
|
18
|
+
phases: [
|
|
19
|
+
{ phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },
|
|
20
|
+
{ phase: 'holdIn', duration: 7000, targetScale: 1.35, label: 'Hold' },
|
|
21
|
+
{ phase: 'exhale', duration: 8000, targetScale: 0.75, label: 'Breathe Out' },
|
|
22
|
+
],
|
|
23
|
+
cycles: 4,
|
|
24
|
+
},
|
|
25
|
+
box: {
|
|
26
|
+
phases: [
|
|
27
|
+
{ phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },
|
|
28
|
+
{ phase: 'holdIn', duration: 4000, targetScale: 1.35, label: 'Hold' },
|
|
29
|
+
{ phase: 'exhale', duration: 4000, targetScale: 0.75, label: 'Breathe Out' },
|
|
30
|
+
{ phase: 'holdOut', duration: 4000, targetScale: 0.75, label: 'Hold' },
|
|
31
|
+
],
|
|
32
|
+
cycles: 4,
|
|
33
|
+
},
|
|
34
|
+
energizing: {
|
|
35
|
+
phases: [
|
|
36
|
+
{ phase: 'inhale', duration: 2000, targetScale: 1.35, label: 'Breathe In' },
|
|
37
|
+
{ phase: 'exhale', duration: 2000, targetScale: 0.75, label: 'Breathe Out' },
|
|
38
|
+
],
|
|
39
|
+
cycles: 10,
|
|
40
|
+
},
|
|
41
|
+
calming: {
|
|
42
|
+
phases: [
|
|
43
|
+
{ phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },
|
|
44
|
+
{ phase: 'exhale', duration: 6000, targetScale: 0.75, label: 'Breathe Out' },
|
|
45
|
+
],
|
|
46
|
+
cycles: 6,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
export function getBreathingPreset(preset) {
|
|
50
|
+
return BREATHING_PRESETS[preset];
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=ExpoBreathingExerciseModule.js.map
|