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.
Files changed (41) hide show
  1. package/README.md +209 -1
  2. package/android/build.gradle +2 -0
  3. package/android/src/main/java/expo/modules/breathing/BreathingConfiguration.kt +25 -0
  4. package/android/src/main/java/expo/modules/breathing/BreathingExerciseView.kt +610 -0
  5. package/android/src/main/java/expo/modules/breathing/BreathingSharedState.kt +108 -0
  6. package/android/src/main/java/expo/modules/breathing/BreathingTextCue.kt +51 -0
  7. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseModule.kt +177 -0
  8. package/android/src/main/java/expo/modules/breathing/ExpoBreathingExerciseView.kt +144 -0
  9. package/android/src/main/java/expo/modules/breathing/MorphingBlobView.kt +128 -0
  10. package/android/src/main/java/expo/modules/breathing/ProgressRingView.kt +50 -0
  11. package/build/ExpoBreathingExercise.types.d.ts +48 -0
  12. package/build/ExpoBreathingExercise.types.d.ts.map +1 -0
  13. package/build/ExpoBreathingExercise.types.js +2 -0
  14. package/build/ExpoBreathingExercise.types.js.map +1 -0
  15. package/build/ExpoBreathingExerciseModule.d.ts +16 -0
  16. package/build/ExpoBreathingExerciseModule.d.ts.map +1 -0
  17. package/build/ExpoBreathingExerciseModule.js +52 -0
  18. package/build/ExpoBreathingExerciseModule.js.map +1 -0
  19. package/build/ExpoBreathingExerciseView.d.ts +4 -0
  20. package/build/ExpoBreathingExerciseView.d.ts.map +1 -0
  21. package/build/ExpoBreathingExerciseView.js +7 -0
  22. package/build/ExpoBreathingExerciseView.js.map +1 -0
  23. package/build/index.d.ts +3 -0
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +3 -0
  26. package/build/index.js.map +1 -1
  27. package/expo-module.config.json +2 -2
  28. package/ios/Breathing/BreathingConfiguration.swift +57 -0
  29. package/ios/Breathing/BreathingExerciseView.swift +451 -0
  30. package/ios/Breathing/BreathingParticlesView.swift +81 -0
  31. package/ios/Breathing/BreathingSharedState.swift +84 -0
  32. package/ios/Breathing/BreathingTextCue.swift +14 -0
  33. package/ios/Breathing/MorphingBlobView.swift +242 -0
  34. package/ios/Breathing/ProgressRingView.swift +27 -0
  35. package/ios/ExpoBreathingExerciseModule.swift +182 -0
  36. package/ios/ExpoBreathingExerciseView.swift +124 -0
  37. package/package.json +8 -5
  38. package/src/ExpoBreathingExercise.types.ts +50 -0
  39. package/src/ExpoBreathingExerciseModule.ts +67 -0
  40. package/src/ExpoBreathingExerciseView.tsx +11 -0
  41. package/src/index.ts +11 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoBreathingExerciseModule.js","sourceRoot":"","sources":["../src/ExpoBreathingExerciseModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAUzD,MAAM,MAAM,GAAG,mBAAmB,CAAkC,uBAAuB,CAAC,CAAC;AAE7F,eAAe,MAAM,CAAC;AAEtB,MAAM,UAAU,sBAAsB,CAAC,OAAyB;IAC9D,MAAM,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,qBAAqB;IACnC,MAAM,CAAC,qBAAqB,EAAE,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,MAAM,CAAC,sBAAsB,EAAE,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,MAAM,CAAC,uBAAuB,EAAE,CAAC;AACnC,CAAC;AAED,MAAM,iBAAiB,GAA8C;IACnE,QAAQ,EAAE;QACR,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;YACrE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,CAAC;KACV;IACD,GAAG,EAAE;QACH,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;YACrE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;YAC5E,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE;SACvE;QACD,MAAM,EAAE,CAAC;KACV;IACD,UAAU,EAAE;QACV,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,EAAE;KACX;IACD,OAAO,EAAE;QACP,MAAM,EAAE;YACN,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE;YAC3E,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE;SAC7E;QACD,MAAM,EAAE,CAAC;KACV;CACF,CAAC;AAEF,MAAM,UAAU,kBAAkB,CAAC,MAAuB;IACxD,OAAO,iBAAiB,CAAC,MAAM,CAAC,CAAC;AACnC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\nimport type { BreathingPattern, BreathingPreset } from './ExpoBreathingExercise.types';\n\ndeclare class ExpoBreathingExerciseModuleType extends NativeModule {\n startBreathingExercise(pattern: BreathingPattern): void;\n stopBreathingExercise(): void;\n pauseBreathingExercise(): void;\n resumeBreathingExercise(): void;\n}\n\nconst module = requireNativeModule<ExpoBreathingExerciseModuleType>('ExpoBreathingExercise');\n\nexport default module;\n\nexport function startBreathingExercise(pattern: BreathingPattern): void {\n module.startBreathingExercise(pattern);\n}\n\nexport function stopBreathingExercise(): void {\n module.stopBreathingExercise();\n}\n\nexport function pauseBreathingExercise(): void {\n module.pauseBreathingExercise();\n}\n\nexport function resumeBreathingExercise(): void {\n module.resumeBreathingExercise();\n}\n\nconst BREATHING_PRESETS: Record<BreathingPreset, BreathingPattern> = {\n relaxing: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'holdIn', duration: 7000, targetScale: 1.35, label: 'Hold' },\n { phase: 'exhale', duration: 8000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 4,\n },\n box: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'holdIn', duration: 4000, targetScale: 1.35, label: 'Hold' },\n { phase: 'exhale', duration: 4000, targetScale: 0.75, label: 'Breathe Out' },\n { phase: 'holdOut', duration: 4000, targetScale: 0.75, label: 'Hold' },\n ],\n cycles: 4,\n },\n energizing: {\n phases: [\n { phase: 'inhale', duration: 2000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'exhale', duration: 2000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 10,\n },\n calming: {\n phases: [\n { phase: 'inhale', duration: 4000, targetScale: 1.35, label: 'Breathe In' },\n { phase: 'exhale', duration: 6000, targetScale: 0.75, label: 'Breathe Out' },\n ],\n cycles: 6,\n },\n};\n\nexport function getBreathingPreset(preset: BreathingPreset): BreathingPattern {\n return BREATHING_PRESETS[preset];\n}\n"]}
@@ -0,0 +1,4 @@
1
+ import * as React from 'react';
2
+ import { ExpoBreathingExerciseViewProps } from './ExpoBreathingExercise.types';
3
+ export default function ExpoBreathingExerciseView(props: ExpoBreathingExerciseViewProps): React.JSX.Element;
4
+ //# sourceMappingURL=ExpoBreathingExerciseView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoBreathingExerciseView.d.ts","sourceRoot":"","sources":["../src/ExpoBreathingExerciseView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,8BAA8B,EAAE,MAAM,+BAA+B,CAAC;AAK/E,MAAM,CAAC,OAAO,UAAU,yBAAyB,CAAC,KAAK,EAAE,8BAA8B,qBAEtF"}
@@ -0,0 +1,7 @@
1
+ import { requireNativeView } from 'expo';
2
+ import * as React from 'react';
3
+ const NativeView = requireNativeView('ExpoBreathingExercise');
4
+ export default function ExpoBreathingExerciseView(props) {
5
+ return <NativeView {...props}/>;
6
+ }
7
+ //# sourceMappingURL=ExpoBreathingExerciseView.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoBreathingExerciseView.js","sourceRoot":"","sources":["../src/ExpoBreathingExerciseView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACzC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,UAAU,GACd,iBAAiB,CAAC,uBAAuB,CAAC,CAAC;AAE7C,MAAM,CAAC,OAAO,UAAU,yBAAyB,CAAC,KAAqC;IACrF,OAAO,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,EAAG,CAAC;AACnC,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport * as React from 'react';\n\nimport { ExpoBreathingExerciseViewProps } from './ExpoBreathingExercise.types';\n\nconst NativeView: React.ComponentType<ExpoBreathingExerciseViewProps> =\n requireNativeView('ExpoBreathingExercise');\n\nexport default function ExpoBreathingExerciseView(props: ExpoBreathingExerciseViewProps) {\n return <NativeView {...props} />;\n}\n"]}
package/build/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { default, setOrbActivity } from './ExpoOrbModule';
2
2
  export { default as ExpoOrbView } from './ExpoOrbView';
3
3
  export * from './ExpoOrb.types';
4
+ export { default as ExpoBreathingExerciseModule, startBreathingExercise, stopBreathingExercise, pauseBreathingExercise, resumeBreathingExercise, getBreathingPreset, } from './ExpoBreathingExerciseModule';
5
+ export { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';
6
+ export * from './ExpoBreathingExercise.types';
4
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC;AAEjC,OAAO,EACL,OAAO,IAAI,2BAA2B,EACtC,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACnF,cAAc,+BAA+B,CAAC"}
package/build/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { default, setOrbActivity } from './ExpoOrbModule';
2
2
  export { default as ExpoOrbView } from './ExpoOrbView';
3
3
  export * from './ExpoOrb.types';
4
+ export { default as ExpoBreathingExerciseModule, startBreathingExercise, stopBreathingExercise, pauseBreathingExercise, resumeBreathingExercise, getBreathingPreset, } from './ExpoBreathingExerciseModule';
5
+ export { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';
6
+ export * from './ExpoBreathingExercise.types';
4
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC","sourcesContent":["export { default, setOrbActivity } from './ExpoOrbModule';\nexport { default as ExpoOrbView } from './ExpoOrbView';\nexport * from './ExpoOrb.types';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,cAAe,iBAAiB,CAAC;AAEjC,OAAO,EACL,OAAO,IAAI,2BAA2B,EACtC,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACnF,cAAc,+BAA+B,CAAC","sourcesContent":["export { default, setOrbActivity } from './ExpoOrbModule';\nexport { default as ExpoOrbView } from './ExpoOrbView';\nexport * from './ExpoOrb.types';\n\nexport {\n default as ExpoBreathingExerciseModule,\n startBreathingExercise,\n stopBreathingExercise,\n pauseBreathingExercise,\n resumeBreathingExercise,\n getBreathingPreset,\n} from './ExpoBreathingExerciseModule';\nexport { default as ExpoBreathingExerciseView } from './ExpoBreathingExerciseView';\nexport * from './ExpoBreathingExercise.types';\n"]}
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "platforms": ["apple", "android", "web"],
3
3
  "apple": {
4
- "modules": ["ExpoOrbModule"]
4
+ "modules": ["ExpoOrbModule", "ExpoBreathingExerciseModule"]
5
5
  },
6
6
  "android": {
7
- "modules": ["expo.modules.orb.ExpoOrbModule"]
7
+ "modules": ["expo.modules.orb.ExpoOrbModule", "expo.modules.breathing.ExpoBreathingExerciseModule"]
8
8
  }
9
9
  }
@@ -0,0 +1,57 @@
1
+ import SwiftUI
2
+
3
+ public struct BreathingConfiguration {
4
+ var blobColors: [Color]
5
+ var innerBlobColor: Color
6
+ var glowColor: Color
7
+ var particleColor: Color
8
+ var progressRingColor: Color
9
+ var textColor: Color
10
+ var showProgressRing: Bool
11
+ var showTextCue: Bool
12
+ var showInnerBlob: Bool
13
+ var showShadow: Bool
14
+ var showParticles: Bool
15
+ var showWavyBlobs: Bool
16
+ var showGlowEffects: Bool
17
+ var pointCount: Int
18
+ var wobbleIntensity: Double
19
+
20
+ public init(
21
+ blobColors: [Color] = [
22
+ Color(red: 0.4, green: 0.7, blue: 0.9),
23
+ Color(red: 0.3, green: 0.5, blue: 0.8),
24
+ Color(red: 0.5, green: 0.3, blue: 0.7)
25
+ ],
26
+ innerBlobColor: Color = Color.white.opacity(0.3),
27
+ glowColor: Color = .white,
28
+ particleColor: Color = .white,
29
+ progressRingColor: Color = Color.white.opacity(0.5),
30
+ textColor: Color = .white,
31
+ showProgressRing: Bool = true,
32
+ showTextCue: Bool = true,
33
+ showInnerBlob: Bool = true,
34
+ showShadow: Bool = true,
35
+ showParticles: Bool = true,
36
+ showWavyBlobs: Bool = true,
37
+ showGlowEffects: Bool = true,
38
+ pointCount: Int = 8,
39
+ wobbleIntensity: Double = 1.0
40
+ ) {
41
+ self.blobColors = blobColors
42
+ self.innerBlobColor = innerBlobColor
43
+ self.glowColor = glowColor
44
+ self.particleColor = particleColor
45
+ self.progressRingColor = progressRingColor
46
+ self.textColor = textColor
47
+ self.showProgressRing = showProgressRing
48
+ self.showTextCue = showTextCue
49
+ self.showInnerBlob = showInnerBlob
50
+ self.showShadow = showShadow
51
+ self.showParticles = showParticles
52
+ self.showWavyBlobs = showWavyBlobs
53
+ self.showGlowEffects = showGlowEffects
54
+ self.pointCount = pointCount
55
+ self.wobbleIntensity = wobbleIntensity
56
+ }
57
+ }
@@ -0,0 +1,451 @@
1
+ import SwiftUI
2
+
3
+ public struct BreathingExerciseView: View {
4
+ private let config: BreathingConfiguration
5
+
6
+ public init(configuration: BreathingConfiguration = BreathingConfiguration()) {
7
+ self.config = configuration
8
+ }
9
+
10
+ public var body: some View {
11
+ TimelineView(.animation) { timeline in
12
+ GeometryReader { geometry in
13
+ let size = min(geometry.size.width, geometry.size.height)
14
+ let elapsedTime = timeline.date.timeIntervalSinceReferenceDate
15
+
16
+ // Update all animation state
17
+ let animState = updateAllAnimations(at: timeline.date, pointCount: config.pointCount)
18
+
19
+ ZStack {
20
+ // Progress ring (behind blob)
21
+ if config.showProgressRing && animState.isActive {
22
+ ProgressRingView(
23
+ progress: animState.phaseProgress,
24
+ color: config.progressRingColor,
25
+ lineWidth: 4,
26
+ size: size * 0.95
27
+ )
28
+ }
29
+
30
+ // Base gradient background layer
31
+ LinearGradient(
32
+ colors: config.blobColors,
33
+ startPoint: .bottom,
34
+ endPoint: .top
35
+ )
36
+
37
+ // Base depth glows - creates depth with rotating glow effects
38
+ baseDepthGlows(size: size, elapsedTime: elapsedTime)
39
+
40
+ // Wavy blobs - adds organic movement with flowing blob shapes
41
+ if config.showWavyBlobs {
42
+ wavyBlob(size: size, elapsedTime: elapsedTime)
43
+ wavyBlobTwo(size: size, elapsedTime: elapsedTime)
44
+ }
45
+
46
+ // Core glow effects - adds bright, energetic core glow animations
47
+ if config.showGlowEffects {
48
+ coreGlowEffects(size: size, elapsedTime: elapsedTime)
49
+ }
50
+
51
+ // Particles - overlays floating particle effects for additional dynamism
52
+ if config.showParticles {
53
+ particleView
54
+ .frame(maxWidth: size, maxHeight: size)
55
+ }
56
+ }
57
+ // Inner glows overlay for depth
58
+ .overlay {
59
+ realisticInnerGlows
60
+ }
61
+ // Mask with morphing blob shape
62
+ .mask {
63
+ MorphingBlobShape(
64
+ center: CGPoint(x: size / 2, y: size / 2),
65
+ baseRadius: animState.scale * 0.7 * size / 2,
66
+ pointCount: config.pointCount,
67
+ offsets: animState.wobbleOffsets
68
+ )
69
+ }
70
+ .frame(width: size, height: size)
71
+ .overlay {
72
+ // Text cue
73
+ if config.showTextCue && !animState.label.isEmpty {
74
+ BreathingTextCue(
75
+ text: animState.label,
76
+ color: config.textColor
77
+ )
78
+ }
79
+ }
80
+ .modifier(
81
+ BreathingShadowModifier(
82
+ colors: config.showShadow ? config.blobColors : [.clear],
83
+ radius: size * 0.06,
84
+ scale: animState.scale
85
+ )
86
+ )
87
+ }
88
+ .aspectRatio(1, contentMode: .fit)
89
+ }
90
+ }
91
+
92
+ // MARK: - Visual Effects (from OrbView)
93
+
94
+ private var particleView: some View {
95
+ ZStack {
96
+ BreathingParticlesView(
97
+ color: config.particleColor,
98
+ particleCount: 15,
99
+ speedRange: 10...20,
100
+ sizeRange: 1...3,
101
+ opacityRange: 0.1...0.4
102
+ )
103
+ .blur(radius: 1)
104
+
105
+ BreathingParticlesView(
106
+ color: config.particleColor,
107
+ particleCount: 10,
108
+ speedRange: 20...35,
109
+ sizeRange: 0.5...2,
110
+ opacityRange: 0.3...0.7
111
+ )
112
+ }
113
+ .blendMode(.plusLighter)
114
+ }
115
+
116
+ private func wavyBlob(size: CGFloat, elapsedTime: Double) -> some View {
117
+ let blobSpeed: Double = 20
118
+
119
+ return RotatingGlowView(
120
+ color: .white.opacity(0.75),
121
+ rotationSpeed: blobSpeed * 1.5,
122
+ direction: .clockwise,
123
+ elapsedTime: elapsedTime
124
+ )
125
+ .mask {
126
+ WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 1.75)
127
+ .frame(maxWidth: size * 1.875)
128
+ .offset(x: 0, y: size * 0.31)
129
+ }
130
+ .blur(radius: 1)
131
+ .blendMode(.plusLighter)
132
+ }
133
+
134
+ private func wavyBlobTwo(size: CGFloat, elapsedTime: Double) -> some View {
135
+ let blobSpeed: Double = 20
136
+
137
+ return RotatingGlowView(
138
+ color: .white,
139
+ rotationSpeed: blobSpeed * 0.75,
140
+ direction: .counterClockwise,
141
+ elapsedTime: elapsedTime
142
+ )
143
+ .mask {
144
+ WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 2.25)
145
+ .frame(maxWidth: size * 1.25)
146
+ .rotationEffect(.degrees(90))
147
+ .offset(x: 0, y: size * -0.31)
148
+ }
149
+ .opacity(0.5)
150
+ .blur(radius: 1)
151
+ .blendMode(.plusLighter)
152
+ }
153
+
154
+ private func coreGlowEffects(size: CGFloat, elapsedTime: Double) -> some View {
155
+ let speed: Double = 18.0
156
+ let glowIntensity: Double = 0.8
157
+
158
+ return ZStack {
159
+ RotatingGlowView(
160
+ color: config.glowColor,
161
+ rotationSpeed: speed * 1.2,
162
+ direction: .clockwise,
163
+ elapsedTime: elapsedTime
164
+ )
165
+ .blur(radius: size * 0.08)
166
+ .opacity(glowIntensity)
167
+
168
+ RotatingGlowView(
169
+ color: config.glowColor,
170
+ rotationSpeed: speed * 0.9,
171
+ direction: .clockwise,
172
+ elapsedTime: elapsedTime
173
+ )
174
+ .blur(radius: size * 0.06)
175
+ .opacity(glowIntensity)
176
+ .blendMode(.plusLighter)
177
+ }
178
+ .padding(size * 0.08)
179
+ }
180
+
181
+ private func baseDepthGlows(size: CGFloat, elapsedTime: Double) -> some View {
182
+ let speed: Double = 18.0
183
+
184
+ return ZStack {
185
+ // Outer glow
186
+ RotatingGlowView(
187
+ color: config.glowColor,
188
+ rotationSpeed: speed * 0.75,
189
+ direction: .counterClockwise,
190
+ elapsedTime: elapsedTime
191
+ )
192
+ .padding(size * 0.03)
193
+ .blur(radius: size * 0.06)
194
+ .rotationEffect(.degrees(180))
195
+ .blendMode(.destinationOver)
196
+
197
+ // Outer ring
198
+ RotatingGlowView(
199
+ color: config.glowColor.opacity(0.5),
200
+ rotationSpeed: speed * 0.25,
201
+ direction: .clockwise,
202
+ elapsedTime: elapsedTime
203
+ )
204
+ .frame(maxWidth: size * 0.94)
205
+ .rotationEffect(.degrees(180))
206
+ .padding(8)
207
+ .blur(radius: size * 0.032)
208
+ }
209
+ }
210
+
211
+ private var orbOutlineColor: LinearGradient {
212
+ LinearGradient(
213
+ colors: [.white, .clear],
214
+ startPoint: .bottom,
215
+ endPoint: .top
216
+ )
217
+ }
218
+
219
+ private var realisticInnerGlows: some View {
220
+ ZStack {
221
+ // Outer stroke with heavy blur
222
+ Circle()
223
+ .stroke(orbOutlineColor, lineWidth: 8)
224
+ .blur(radius: 32)
225
+ .blendMode(.plusLighter)
226
+
227
+ // Inner stroke with light blur
228
+ Circle()
229
+ .stroke(orbOutlineColor, lineWidth: 4)
230
+ .blur(radius: 12)
231
+ .blendMode(.plusLighter)
232
+
233
+ Circle()
234
+ .stroke(orbOutlineColor, lineWidth: 1)
235
+ .blur(radius: 4)
236
+ .blendMode(.plusLighter)
237
+ }
238
+ .padding(1)
239
+ }
240
+
241
+ private struct AnimState {
242
+ let scale: Double
243
+ let wobbleOffsets: [Double]
244
+ let phaseProgress: Double
245
+ let label: String
246
+ let isActive: Bool
247
+ }
248
+
249
+ private func updateAllAnimations(at date: Date, pointCount: Int) -> AnimState {
250
+ let state = BreathingSharedState.shared
251
+
252
+ // Always update wobble animation (organic movement even when idle)
253
+ updateWobble(at: date, pointCount: pointCount)
254
+
255
+ // Handle breathing state
256
+ switch state.state {
257
+ case .stopped, .complete:
258
+ return AnimState(
259
+ scale: 1.0,
260
+ wobbleOffsets: state.wobbleOffsets,
261
+ phaseProgress: 0,
262
+ label: "",
263
+ isActive: false
264
+ )
265
+
266
+ case .paused:
267
+ return AnimState(
268
+ scale: state.currentScale,
269
+ wobbleOffsets: state.wobbleOffsets,
270
+ phaseProgress: state.phaseProgress,
271
+ label: state.currentLabel,
272
+ isActive: true
273
+ )
274
+
275
+ case .running:
276
+ // Update phase timing
277
+ updatePhaseState(at: date)
278
+
279
+ // Calculate scale and progress based on phase
280
+ let currentPhaseConfig = state.phases.isEmpty ? nil : state.phases[state.currentPhaseIndex]
281
+ let scale: Double
282
+ let ringProgress: Double
283
+
284
+ if let phase = currentPhaseConfig?.phase {
285
+ switch phase {
286
+ case .inhale:
287
+ // Ease-out cubic for smooth deceleration toward target
288
+ let easedProgress = easeOutCubic(state.phaseProgress)
289
+ scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
290
+ ringProgress = easedProgress // 0 -> 1
291
+ case .exhale:
292
+ let easedProgress = easeOutCubic(state.phaseProgress)
293
+ scale = state.startScale + (state.targetScale - state.startScale) * easedProgress
294
+ ringProgress = 1.0 - easedProgress // 1 -> 0
295
+ case .holdIn:
296
+ // Hold at expanded scale, ring stays full
297
+ scale = state.targetScale
298
+ ringProgress = 1.0
299
+ case .holdOut:
300
+ // Hold at contracted scale, ring stays empty
301
+ scale = state.targetScale
302
+ ringProgress = 0.0
303
+ case .idle:
304
+ scale = state.targetScale
305
+ ringProgress = 0.0
306
+ }
307
+ } else {
308
+ scale = 1.0
309
+ ringProgress = 0.0
310
+ }
311
+
312
+ state.currentScale = scale
313
+
314
+ return AnimState(
315
+ scale: scale,
316
+ wobbleOffsets: state.wobbleOffsets,
317
+ phaseProgress: ringProgress,
318
+ label: state.currentLabel,
319
+ isActive: true
320
+ )
321
+ }
322
+ }
323
+
324
+ private func updatePhaseState(at date: Date) {
325
+ let state = BreathingSharedState.shared
326
+ guard !state.phases.isEmpty else { return }
327
+
328
+ let currentPhaseConfig = state.phases[state.currentPhaseIndex]
329
+ let elapsed = date.timeIntervalSince(state.phaseStartTime)
330
+ let duration = currentPhaseConfig.duration
331
+
332
+ // Calculate progress through current phase
333
+ state.phaseProgress = min(1.0, elapsed / duration)
334
+
335
+ // Check if phase is complete
336
+ if elapsed >= duration {
337
+ // Move to next phase
338
+ state.currentPhaseIndex = (state.currentPhaseIndex + 1) % state.phases.count
339
+
340
+ // Check if we completed a cycle
341
+ if state.currentPhaseIndex == 0 {
342
+ state.currentCycle += 1
343
+
344
+ // Check if exercise is complete
345
+ if let totalCycles = state.totalCycles, state.currentCycle >= totalCycles {
346
+ state.state = .complete
347
+ state.totalDuration = date.timeIntervalSince(state.exerciseStartTime)
348
+ state.onExerciseComplete?(state.currentCycle, state.totalDuration)
349
+ return
350
+ }
351
+ }
352
+
353
+ // Start new phase
354
+ state.phaseStartTime = date
355
+ let newPhaseConfig = state.phases[state.currentPhaseIndex]
356
+ state.currentPhase = newPhaseConfig.phase
357
+ state.currentLabel = newPhaseConfig.label
358
+ state.startScale = state.currentScale // Remember where we're starting from
359
+ state.targetScale = newPhaseConfig.targetScale
360
+ state.phaseProgress = 0
361
+
362
+ // Update wobble intensity based on phase
363
+ switch newPhaseConfig.phase {
364
+ case .inhale, .exhale:
365
+ state.wobbleIntensity = 1.0
366
+ case .holdIn, .holdOut:
367
+ state.wobbleIntensity = 0.3
368
+ case .idle:
369
+ state.wobbleIntensity = 0.5
370
+ }
371
+
372
+ // Fire phase change callback
373
+ state.onPhaseChange?(
374
+ newPhaseConfig.phase,
375
+ newPhaseConfig.label,
376
+ state.currentPhaseIndex,
377
+ state.currentCycle
378
+ )
379
+ }
380
+ }
381
+
382
+ private func updateWobble(at date: Date, pointCount: Int) {
383
+ let state = BreathingSharedState.shared
384
+
385
+ // Initialize arrays if needed
386
+ if state.wobbleOffsets.count != pointCount {
387
+ state.wobbleOffsets = (0..<pointCount).map { _ in 0.0 }
388
+ state.wobbleTargets = (0..<pointCount).map { _ in 0.0 }
389
+ state.lastWobbleUpdate = date
390
+ state.lastWobbleTargetUpdate = date
391
+ }
392
+
393
+ let now = date
394
+ let dt = now.timeIntervalSince(state.lastWobbleUpdate)
395
+ state.lastWobbleUpdate = now
396
+
397
+ // Generate new random targets periodically (slower, more organic)
398
+ let timeSinceTargetUpdate = now.timeIntervalSince(state.lastWobbleTargetUpdate)
399
+ if timeSinceTargetUpdate > 1.8 {
400
+ state.lastWobbleTargetUpdate = now
401
+
402
+ let intensity = state.wobbleIntensity * config.wobbleIntensity
403
+ let maxOffset = 0.18 * intensity
404
+
405
+ state.wobbleTargets = (0..<pointCount).map { i in
406
+ let phase = Double(i) / Double(pointCount) * 2.0 * .pi
407
+ let wave1 = sin(phase * 2 + Double.random(in: 0...1)) * 0.6
408
+ let wave2 = cos(phase * 3 + Double.random(in: 0...1)) * 0.4
409
+ let baseOffset = Double.random(in: -maxOffset...maxOffset)
410
+ return baseOffset * (1.0 + wave1 + wave2) * 0.5
411
+ }
412
+ }
413
+
414
+ // Smoothly interpolate current offsets toward targets
415
+ let interpolationSpeed = 1.8
416
+ let factor = min(1.0, dt * interpolationSpeed)
417
+
418
+ for i in 0..<pointCount {
419
+ let current = state.wobbleOffsets[i]
420
+ let target = state.wobbleTargets[i]
421
+ state.wobbleOffsets[i] = current + (target - current) * factor
422
+ }
423
+ }
424
+ }
425
+
426
+ struct BreathingShadowModifier: ViewModifier {
427
+ let colors: [Color]
428
+ let radius: CGFloat
429
+ let scale: Double
430
+
431
+ func body(content: Content) -> some View {
432
+ content
433
+ .shadow(color: colors.first?.opacity(0.4) ?? .clear, radius: radius * scale, x: 0, y: radius * 0.3 * scale)
434
+ .shadow(color: colors.first?.opacity(0.2) ?? .clear, radius: radius * 2 * scale, x: 0, y: radius * 0.5 * scale)
435
+ }
436
+ }
437
+
438
+ // Easing functions
439
+ private func easeOutCubic(_ t: Double) -> Double {
440
+ let t1 = t - 1
441
+ return t1 * t1 * t1 + 1
442
+ }
443
+
444
+ private func easeInOutCubic(_ t: Double) -> Double {
445
+ if t < 0.5 {
446
+ return 4 * t * t * t
447
+ } else {
448
+ let t1 = -2 * t + 2
449
+ return 1 - (t1 * t1 * t1) / 2
450
+ }
451
+ }
@@ -0,0 +1,81 @@
1
+ import SwiftUI
2
+
3
+ /// Pure SwiftUI particle system - no SpriteKit, no console warnings
4
+ struct BreathingParticlesView: View {
5
+ let color: Color
6
+ let particleCount: Int
7
+ let speedRange: ClosedRange<Double>
8
+ let sizeRange: ClosedRange<CGFloat>
9
+ let opacityRange: ClosedRange<Double>
10
+
11
+ init(
12
+ color: Color = .white,
13
+ particleCount: Int = 20,
14
+ speedRange: ClosedRange<Double> = 10...30,
15
+ sizeRange: ClosedRange<CGFloat> = 0.5...2.0,
16
+ opacityRange: ClosedRange<Double> = 0.1...0.8
17
+ ) {
18
+ self.color = color
19
+ self.particleCount = particleCount
20
+ self.speedRange = speedRange
21
+ self.sizeRange = sizeRange
22
+ self.opacityRange = opacityRange
23
+ }
24
+
25
+ var body: some View {
26
+ TimelineView(.animation) { timeline in
27
+ Canvas { context, size in
28
+ let time = timeline.date.timeIntervalSinceReferenceDate
29
+
30
+ for i in 0..<particleCount {
31
+ // Use deterministic seed based on particle index
32
+ let seed = Double(i) * 1.618033988749895 // Golden ratio for good distribution
33
+
34
+ // Particle properties derived from seed
35
+ let baseX = fract(seed * 0.7)
36
+ let _ = fract(seed * 1.3) // Reserved for future horizontal variation
37
+ let particleSpeed = speedRange.lowerBound + fract(seed * 2.1) * (speedRange.upperBound - speedRange.lowerBound)
38
+ let particleSize = sizeRange.lowerBound + CGFloat(fract(seed * 3.7)) * (sizeRange.upperBound - sizeRange.lowerBound)
39
+ let baseOpacity = opacityRange.lowerBound + fract(seed * 4.3) * (opacityRange.upperBound - opacityRange.lowerBound)
40
+ let lifetime = 2.0 + fract(seed * 5.1) * 2.0 // 2-4 seconds
41
+
42
+ // Calculate current position in lifecycle
43
+ let phase = fract((time * particleSpeed / 100.0 + seed) / lifetime)
44
+
45
+ // Y position: rise from bottom to top
46
+ let y = size.height * (1.0 - phase)
47
+
48
+ // X position: slight horizontal drift
49
+ let drift = sin(time * 0.5 + seed * 10) * 20
50
+ let x = baseX * size.width + drift
51
+
52
+ // Opacity: fade in and out
53
+ let fadeIn = min(1.0, phase * 5.0) // Quick fade in
54
+ let fadeOut = min(1.0, (1.0 - phase) * 3.0) // Slower fade out
55
+ let opacity = baseOpacity * fadeIn * fadeOut
56
+
57
+ // Scale: grow then shrink
58
+ let scalePhase = phase < 0.3 ? phase / 0.3 : (1.0 - (phase - 0.3) / 0.7)
59
+ let scale = 0.5 + scalePhase * 0.5
60
+
61
+ // Draw particle
62
+ let rect = CGRect(
63
+ x: x - particleSize * scale / 2,
64
+ y: y - particleSize * scale / 2,
65
+ width: particleSize * scale,
66
+ height: particleSize * scale
67
+ )
68
+
69
+ context.fill(
70
+ Circle().path(in: rect),
71
+ with: .color(color.opacity(opacity))
72
+ )
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ private func fract(_ x: Double) -> Double {
79
+ x - floor(x)
80
+ }
81
+ }