@@ -73,6 +73,33 @@ extension Shape {
7373 }
7474}
7575
76+ // Traces half the screen perimeter: bottom-center → corner → side → top-center.
77+ // Used for the hold-phase ripple effect. `rightSide: true` goes clockwise (right),
78+ // `rightSide: false` goes counter-clockwise (left).
79+ struct HalfPerimeterShape : Shape {
80+ let rightSide : Bool
81+
82+ func path( in rect: CGRect ) -> Path {
83+ var path = Path ( )
84+ let w = rect. width
85+ let h = rect. height
86+
87+ if rightSide {
88+ path. move ( to: CGPoint ( x: w / 2 , y: h) )
89+ path. addLine ( to: CGPoint ( x: w, y: h) )
90+ path. addLine ( to: CGPoint ( x: w, y: 0 ) )
91+ path. addLine ( to: CGPoint ( x: w / 2 , y: 0 ) )
92+ } else {
93+ path. move ( to: CGPoint ( x: w / 2 , y: h) )
94+ path. addLine ( to: CGPoint ( x: 0 , y: h) )
95+ path. addLine ( to: CGPoint ( x: 0 , y: 0 ) )
96+ path. addLine ( to: CGPoint ( x: w / 2 , y: 0 ) )
97+ }
98+
99+ return path
100+ }
101+ }
102+
76103struct ContentView : View {
77104 @EnvironmentObject var settingsModel : SettingsModel
78105 @State private var animationProgress : CGFloat = 0
@@ -82,6 +109,8 @@ struct ContentView: View {
82109 @State private var cycleCount : Int = 0
83110 @State private var cachedMaxCircleScale : CGFloat = 1
84111 @State private var animationSessionIdentifier : Int = 0
112+ @State private var holdProgress : CGFloat = 0
113+ @State private var rippleOpacity : Double = 0
85114 var body : some View {
86115 ZStack {
87116 GeometryReader { geometry in
@@ -151,6 +180,45 @@ struct ContentView: View {
151180 }
152181 }
153182 . opacity ( settingsModel. overlayOpacity)
183+
184+ // Screen-edge ripple during hold phases (driven by rippleOpacity)
185+ if settingsModel. holdRippleEnabled && rippleOpacity > 0 {
186+ let isExhale = breathingPhase == . holdAfterExhale
187+ || ( breathingPhase == . inhale && holdProgress > 0 )
188+ let phaseColor = isExhale
189+ ? settingsModel. cachedExhaleColor
190+ : settingsModel. cachedInhaleColor
191+ let borderUnit = min ( geometry. size. width, geometry. size. height) * 0.04
192+ let useGradient = settingsModel. holdRippleMode == . gradient
193+
194+ let trailFrom = isExhale ? holdProgress : 0 as CGFloat
195+ let trailTo = isExhale ? 1 as CGFloat : holdProgress
196+ let bandFrom = isExhale ? holdProgress : max ( 0 , holdProgress - 0.12 )
197+ let bandTo = isExhale ? min ( 1 , holdProgress + 0.12 ) : holdProgress
198+
199+ // When blurred, use a wider stroke so the glow is visible after
200+ // the blur spreads it. The blur softens ALL edges: inner (toward
201+ // center), and leading/trailing (along the perimeter).
202+ let strokeWidth = useGradient ? borderUnit * 3 : borderUnit * 2
203+ let blurRadius = useGradient ? borderUnit * 2 : 0 as CGFloat
204+
205+ Group {
206+ ForEach ( [ true , false ] , id: \. self) { rightSide in
207+ // Trail glow (fills behind the sweep front)
208+ HalfPerimeterShape ( rightSide: rightSide)
209+ . trim ( from: trailFrom, to: trailTo)
210+ . stroke ( phaseColor, style: StrokeStyle ( lineWidth: strokeWidth, lineCap: . butt) )
211+ . opacity ( 0.25 )
212+ // Leading band (bright sweep at the front)
213+ HalfPerimeterShape ( rightSide: rightSide)
214+ . trim ( from: bandFrom, to: bandTo)
215+ . stroke ( phaseColor, style: StrokeStyle ( lineWidth: strokeWidth, lineCap: . butt) )
216+ . opacity ( 0.8 )
217+ }
218+ }
219+ . blur ( radius: blurRadius)
220+ . opacity ( rippleOpacity)
221+ }
154222 }
155223 }
156224 }
@@ -180,6 +248,7 @@ struct ContentView: View {
180248 randomizedTimingPostInhaleHold: $settingsModel. randomizedTimingPostInhaleHold,
181249 randomizedTimingExhale: $settingsModel. randomizedTimingExhale,
182250 randomizedTimingPostExhaleHold: $settingsModel. randomizedTimingPostExhaleHold,
251+ holdRippleMode: $settingsModel. holdRippleMode,
183252 isAnimating: $settingsModel. isAnimating,
184253 appVisibility: $settingsModel. appVisibility,
185254 reminderIntervalMinutes: $settingsModel. reminderIntervalMinutes,
@@ -247,6 +316,11 @@ struct ContentView: View {
247316 ? . linear( duration: duration)
248317 : . timingCurve( 0.42 , 0 , 0.58 , 1 , duration: duration)
249318
319+ // Fade ripple out over the first 10% of the inhale
320+ withAnimation ( . linear( duration: duration * 0.1 ) ) {
321+ rippleOpacity = 0
322+ }
323+
250324 withAnimation ( animation) {
251325 breathingPhase = . inhale
252326 animationProgress = 1.0
@@ -269,6 +343,13 @@ struct ContentView: View {
269343 }
270344 duration = max ( duration, 0.1 )
271345 breathingPhase = . holdAfterInhale
346+ if settingsModel. holdRippleEnabled && settingsModel. postInhaleHoldDuration > 0 {
347+ holdProgress = 0
348+ rippleOpacity = 1
349+ withAnimation ( . linear( duration: duration) ) {
350+ holdProgress = 1
351+ }
352+ }
272353 DispatchQueue . main. asyncAfter ( deadline: . now( ) + duration) {
273354 guard currentAnimationSessionIdentifier == self . animationSessionIdentifier else { return }
274355 self . exhale ( )
@@ -288,6 +369,11 @@ struct ContentView: View {
288369 ? . linear( duration: duration)
289370 : . timingCurve( 0.42 , 0 , 0.58 , 1 , duration: duration)
290371
372+ // Fade ripple out over the first 10% of the exhale
373+ withAnimation ( . linear( duration: duration * 0.1 ) ) {
374+ rippleOpacity = 0
375+ }
376+
291377 withAnimation ( animation) {
292378 breathingPhase = . exhale
293379 animationProgress = 0.0
@@ -307,7 +393,13 @@ struct ContentView: View {
307393 }
308394 duration = max ( duration, 0.1 )
309395 breathingPhase = . holdAfterExhale
310-
396+ if settingsModel. holdRippleEnabled && settingsModel. postExhaleHoldDuration > 0 {
397+ holdProgress = 1
398+ rippleOpacity = 1
399+ withAnimation ( . linear( duration: duration) ) {
400+ holdProgress = 0
401+ }
402+ }
311403 DispatchQueue . main. asyncAfter ( deadline: . now( ) + duration) {
312404 guard currentAnimationSessionIdentifier == self . animationSessionIdentifier else { return }
313405 guard self . settingsModel. isAnimating else { return self . resetAnimation ( ) }
@@ -320,14 +412,17 @@ struct ContentView: View {
320412 animationSessionIdentifier += 1
321413 cycleCount = 0
322414 animationProgress = 0.0
415+ holdProgress = 0
416+ rippleOpacity = 0
323417 breathingPhase = . inhale
324418 }
325419
326420 func stopCurrentAnimation( ) {
327- // Stop the current animation
328421 animationSessionIdentifier += 1
329422 cycleCount = 0
330423 animationProgress = 0.0
424+ holdProgress = 0
425+ rippleOpacity = 0
331426 }
332427
333428 func resumeBreathingCycle( ) {
0 commit comments