<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: garry</title>
    <description>The latest articles on DEV Community by garry (@lalo132).</description>
    <link>https://dev.to/lalo132</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3836135%2Fb4e60399-d21a-4754-983d-3bf165c13af9.png</url>
      <title>DEV Community: garry</title>
      <link>https://dev.to/lalo132</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lalo132"/>
    <language>en</language>
    <item>
      <title>How I Handle State Management in a Health App</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Fri, 27 Mar 2026 16:27:50 +0000</pubDate>
      <link>https://dev.to/lalo132/how-i-handle-state-management-in-a-health-app-2ma4</link>
      <guid>https://dev.to/lalo132/how-i-handle-state-management-in-a-health-app-2ma4</guid>
      <description>&lt;h2&gt;
  
  
  State in a Breathing App Is Deceptively Complex
&lt;/h2&gt;

&lt;p&gt;On the surface, &lt;a href="https://socialhub-links.darian-hanci.workers.dev/lunair?ref=devto-578A9E5B" rel="noopener noreferrer"&gt;Lunair&lt;/a&gt; seems like it would have simple state: a breathing pattern is playing or it is not. In reality, the state graph is surprisingly deep once you account for interruptions, background transitions, accessibility events, and user preferences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The State Machine Approach
&lt;/h2&gt;

&lt;p&gt;Early on I used a collection of boolean flags — &lt;code&gt;isPlaying&lt;/code&gt;, &lt;code&gt;isPaused&lt;/code&gt;, &lt;code&gt;isComplete&lt;/code&gt;. This quickly devolved into impossible states. Could something be both paused and complete? The flags said yes; reality said no.&lt;/p&gt;

&lt;p&gt;I switched to an explicit state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Equatable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;preparing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BreathPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BreathPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;cycleCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;resumePhase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;BreathPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;cycleCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;completing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;totalCycles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionSummary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every possible state is explicitly modeled. The &lt;code&gt;paused&lt;/code&gt; case carries the information needed to resume. The &lt;code&gt;completing&lt;/code&gt; state handles the wind-down animation before showing results. No impossible combinations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing Transitions
&lt;/h2&gt;

&lt;p&gt;State transitions are handled through a single method that validates and applies changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;SessionManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="nf"&gt;isValidTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;assertionFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid transition: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;oldState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;

        &lt;span class="nf"&gt;handleSideEffects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oldState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;isValidTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preparing&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preparing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;         &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completing&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;              &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;assertionFailure&lt;/code&gt; catches invalid transitions during development without crashing in production. This has caught bugs multiple times — especially around background/foreground transitions triggering unexpected state changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side Effects at the Boundary
&lt;/h2&gt;

&lt;p&gt;All side effects — haptics, sound, analytics, timer management — happen in &lt;code&gt;handleSideEffects&lt;/code&gt;, not scattered throughout the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;handleSideEffects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;oldState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SessionState&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;hapticEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signalPhase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;timerManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;timerManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;hapticEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cycles&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;hapticEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signalSessionComplete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logSessionEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cycles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cycles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This centralization means the views stay purely declarative. They read state and render. That is it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting Across Interruptions
&lt;/h2&gt;

&lt;p&gt;Phone calls, notifications, backgrounding — all of these can interrupt a breathing session. The state machine handles this cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;handleScenePhaseChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ScenePhase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;breathPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;resumePhase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;breathPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;cycleCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Don't auto-resume — let the user choose&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auto-pausing on background is essential. Auto-resuming is not — returning to the app after a phone call should not immediately start a breathing cycle without the user being ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting Lunair today, I would reach for the Observation framework (&lt;code&gt;@Observable&lt;/code&gt;) sooner. The &lt;code&gt;ObservableObject&lt;/code&gt; + &lt;code&gt;@Published&lt;/code&gt; pattern works, but the newer approach eliminates unnecessary view updates when unrelated published properties change.&lt;/p&gt;

&lt;p&gt;The state machine pattern, though, I would not change. It has been the single most stabilizing architectural decision in the entire app.&lt;/p&gt;

</description>
      <category>indiedev</category>
    </item>
    <item>
      <title>Testing Timer Accuracy on Different iOS Devices</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Tue, 24 Mar 2026 08:17:01 +0000</pubDate>
      <link>https://dev.to/lalo132/testing-timer-accuracy-on-different-ios-devices-482k</link>
      <guid>https://dev.to/lalo132/testing-timer-accuracy-on-different-ios-devices-482k</guid>
      <description>&lt;h2&gt;
  
  
  You Cannot Trust the Simulator
&lt;/h2&gt;

&lt;p&gt;The iOS Simulator runs on your Mac's CPU. Timers in the simulator are suspiciously accurate because they are backed by a desktop processor that is not juggling cellular radios, thermal throttling, or aggressive power management. Real devices lie in different and more interesting ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Testing Methodology
&lt;/h2&gt;

&lt;p&gt;When I built BoxTime, I needed to verify that the timer was accurate across a range of devices. I set up a simple test: start a 3-minute round, compare the app's completion time against an external reference (a stopwatch on another device).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Test Harness
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;TimerAccuracyLogger&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;roundStartTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;expectedDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;roundStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;expectedDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;roundStartTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expectedDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expectedDuration&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;roundEnded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;roundStartTime&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expectedDuration&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Expected: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;expectedDuration&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;s, Actual: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"%.4f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;s, Drift: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"%.4f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;s"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this across five devices: iPhone SE (2nd gen), iPhone 12, iPhone 14 Pro, iPhone 15, and an iPad Air. Each device ran a full 12-round, 3-minute workout with 1-minute rest periods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Per-Round Drift&lt;/th&gt;
&lt;th&gt;Total Drift (36 min)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iPhone SE (2nd gen)&lt;/td&gt;
&lt;td&gt;&amp;lt; 1ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 12&lt;/td&gt;
&lt;td&gt;&amp;lt; 1ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 14 Pro&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.5ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 15&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.5ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPad Air (5th gen)&lt;/td&gt;
&lt;td&gt;&amp;lt; 1ms&lt;/td&gt;
&lt;td&gt;&amp;lt; 12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the absolute-time-anchored approach (computing remaining time from &lt;code&gt;Date()&lt;/code&gt; rather than counting ticks), drift is negligible on all devices. The sub-millisecond drift comes from the time between the &lt;code&gt;Date()&lt;/code&gt; call and the actual notification/haptic firing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Things Go Wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Thermal Throttling
&lt;/h3&gt;

&lt;p&gt;I ran the test while the iPhone SE was charging in a warm room. The device thermal-throttled, and frame rates dropped. But the timer accuracy was unaffected because it is not tied to frame rate. The display update was choppy, but the round ended at the correct time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Low Power Mode
&lt;/h3&gt;

&lt;p&gt;Low Power Mode reduces CPU frequency and limits background activity. Timer accuracy was unaffected in the foreground. However, if the app moves to the background while Low Power Mode is active, iOS is more aggressive about suspending it. The local notification fallback handles this.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Backgrounding
&lt;/h3&gt;

&lt;p&gt;This is the real threat to accuracy. When the app goes to the background, iOS can suspend it within seconds. My test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a 3-minute round&lt;/li&gt;
&lt;li&gt;Switch to Safari for 30 seconds&lt;/li&gt;
&lt;li&gt;Return to BoxTime&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: the timer correctly shows the right remaining time because it recalculates from the absolute end time on every frame after returning to foreground.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;sceneDidBecomeActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIScene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Timer automatically shows correct time because remainingTime&lt;/span&gt;
    &lt;span class="c1"&gt;// is computed from Date(), not from accumulated ticks&lt;/span&gt;
    &lt;span class="c1"&gt;// No manual state restoration needed for the display&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edge Case: System Clock Changes
&lt;/h2&gt;

&lt;p&gt;What if the user changes their system clock mid-workout? This could theoretically break an absolute-time approach. I handle it with &lt;code&gt;ProcessInfo.processInfo.systemUptime&lt;/code&gt; as a sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;validateTimeConsistency&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wallClockElapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workoutStartDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;uptimeElapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProcessInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;systemUptime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;workoutStartUptime&lt;/span&gt;

    &lt;span class="c1"&gt;// If wall clock and uptime disagree by more than 2 seconds,&lt;/span&gt;
    &lt;span class="c1"&gt;// the system clock was probably changed&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wallClockElapsed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uptimeElapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fall back to uptime-based calculation&lt;/span&gt;
        &lt;span class="nf"&gt;recalibrateFromUptime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an edge case I have never seen in production, but it costs five lines of code to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Timer accuracy on iOS is a solved problem if you use the right architecture. Anchor to absolute time, use CADisplayLink for display updates, and handle backgrounding gracefully. Testing on real devices confirms what the theory predicts -- the approach works across the full range of iOS hardware.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-7D134F5D" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt; has been used for thousands of workout sessions with zero reported timing issues.&lt;/p&gt;

</description>
      <category>swiftui</category>
    </item>
    <item>
      <title>Localization for a Fitness App — What I Wish I Knew Earlier</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Mon, 23 Mar 2026 18:27:22 +0000</pubDate>
      <link>https://dev.to/lalo132/localization-for-a-fitness-app-what-i-wish-i-knew-earlier-3clm</link>
      <guid>https://dev.to/lalo132/localization-for-a-fitness-app-what-i-wish-i-knew-earlier-3clm</guid>
      <description>&lt;h2&gt;
  
  
  Boxing Is Global, English Is Not
&lt;/h2&gt;

&lt;p&gt;BoxTime started as an English-only app. Then I looked at my App Store analytics: 40% of my downloads came from non-English-speaking countries. Brazil, Japan, Mexico, Germany. I was leaving value on the table by not localizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  String Catalogs in Xcode 15+
&lt;/h2&gt;

&lt;p&gt;Apple introduced String Catalogs (&lt;code&gt;.xcstrings&lt;/code&gt; files) in Xcode 15, replacing the old &lt;code&gt;.strings&lt;/code&gt; and &lt;code&gt;.stringsdict&lt;/code&gt; files. The migration was worth it -- String Catalogs auto-detect localizable strings and provide a single-file editing experience.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In code, just use String(localized:)&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;localized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Round &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;currentRound&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; of &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;totalRounds&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// Or use the automatic LocalizedStringKey behavior&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Round &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;currentRound&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; of &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;totalRounds&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SwiftUI &lt;code&gt;Text&lt;/code&gt; views automatically treat string literals as &lt;code&gt;LocalizedStringKey&lt;/code&gt;. For a simple app, this means most of your UI is already localization-ready if you used string literals in &lt;code&gt;Text&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenges Specific to Fitness Apps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Number Formatting
&lt;/h3&gt;

&lt;p&gt;Some cultures use commas for decimal separators. Timer displays should use the user's locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: hardcoded format&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"%02d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Right for a timer: this is one case where you actually want&lt;/span&gt;
&lt;span class="c1"&gt;// a fixed format. Timers are universally read as M:SS.&lt;/span&gt;
&lt;span class="c1"&gt;// But display it clearly:&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timerCountdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, timer displays are an exception to the localization rule. "3:00" means three minutes everywhere. I deliberately do not localize the countdown format. But labels around the timer ("Round", "Rest", "Rounds remaining") absolutely need translation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pluralization
&lt;/h3&gt;

&lt;p&gt;"1 round" vs "3 rounds" is straightforward in English. Other languages have complex plural rules. Russian has three plural forms. Arabic has six.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your String Catalog, define plural variants:&lt;/span&gt;
&lt;span class="c1"&gt;// "rounds_remaining" = "%lld rounds remaining"&lt;/span&gt;
&lt;span class="c1"&gt;// With variants for: zero, one, two, few, many, other&lt;/span&gt;

&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rounds_remaining &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;roundsLeft&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String Catalogs handle plural rules per locale. You define the variants, and iOS picks the right one at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layout Flexibility
&lt;/h3&gt;

&lt;p&gt;German words are longer than English. Arabic reads right-to-left. Your layout needs to handle both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: fixed-width container&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phaseLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Good: flexible layout&lt;/span&gt;
&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phaseLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;infinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minimumScaleFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.minimumScaleFactor&lt;/code&gt; modifier lets text shrink to fit rather than truncating. For BoxTime, I set it on all labels that might be longer in other languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Localized (and What I Did Not)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Localized:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;All UI labels ("Round", "Rest", "Start", "Pause", "Settings")&lt;/li&gt;
&lt;li&gt;App Store metadata (title, subtitle, description, keywords)&lt;/li&gt;
&lt;li&gt;Notification text&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Not localized:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Timer display (universal format)&lt;/li&gt;
&lt;li&gt;Sound file names (internal)&lt;/li&gt;
&lt;li&gt;Analytics event names (internal)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Got Translations
&lt;/h2&gt;

&lt;p&gt;For the initial launch, I used a combination of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Machine translation&lt;/strong&gt; (DeepL, not Google Translate) for the first pass&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native speaker review&lt;/strong&gt; from language exchange communities and Reddit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Store metadata&lt;/strong&gt; was the priority -- this is what drives downloads&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I focused on five languages first: Spanish, Portuguese, Japanese, German, and French. These covered my top non-English markets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Impact
&lt;/h2&gt;

&lt;p&gt;After localizing the App Store listing alone (before even localizing the app UI), downloads from localized markets increased by roughly 60%. Localizing the in-app UI further improved retention in those markets.&lt;/p&gt;

&lt;p&gt;If you are an indie dev with a fitness app, check your App Store Connect analytics for your top countries. If you see significant traffic from non-English markets, localization has clear ROI -- even if you start with just the App Store metadata.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-16E04671" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt; is currently localized in six languages, with more planned based on download data.&lt;/p&gt;

</description>
      <category>indiedev</category>
    </item>
    <item>
      <title>Building a Precision Timer in SwiftUI Without Drift</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Mon, 23 Mar 2026 12:17:52 +0000</pubDate>
      <link>https://dev.to/lalo132/building-a-precision-timer-in-swiftui-without-drift-4d1a</link>
      <guid>https://dev.to/lalo132/building-a-precision-timer-in-swiftui-without-drift-4d1a</guid>
      <description>&lt;h2&gt;
  
  
  The Problem With Naive Timers
&lt;/h2&gt;

&lt;p&gt;When I started building BoxTime, my boxing round timer app, I made the classic mistake: I used &lt;code&gt;Timer.scheduledTimer&lt;/code&gt; with a 1-second interval and decremented a counter. It looked right in the simulator. It looked right for the first 30 seconds on a real device. Then the drift crept in.&lt;/p&gt;

&lt;p&gt;After a 3-minute round, my timer was off by 1-2 seconds. Over a full 12-round session, that adds up. In boxing, timing matters. You can't have a round timer that lies to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Timers Drift
&lt;/h2&gt;

&lt;p&gt;The fundamental issue is that &lt;code&gt;Timer&lt;/code&gt; in iOS is not a precision instrument. It fires on the run loop, and the run loop has other things to do. Each tick might be 1.002 seconds instead of 1.000. Those fractions accumulate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The naive approach - DO NOT use this for precision&lt;/span&gt;
&lt;span class="kt"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduledTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;withTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;repeats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remainingSeconds&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run loop might delay a fire by milliseconds. When the UI is busy, it gets worse. Scrolling, animations, haptics -- all of it competes for time on the main thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Anchor to Absolute Time
&lt;/h2&gt;

&lt;p&gt;Instead of counting ticks, I anchor to a reference point using &lt;code&gt;Date&lt;/code&gt; and calculate the elapsed time on every update. The display timer fires frequently, but the actual remaining time is always computed from reality.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;RoundTimer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;roundEndTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;displayLink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CADisplayLink&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;remainingSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;roundEndTime&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSinceNow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startRound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;roundEndTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;startDisplayUpdates&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startDisplayUpdates&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;displayLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CADisplayLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;#selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;displayLink&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;@objc&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// View updates automatically via @Observable&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remainingSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;roundDidEnd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why CADisplayLink Over Timer
&lt;/h2&gt;

&lt;p&gt;I use &lt;code&gt;CADisplayLink&lt;/code&gt; instead of a repeating &lt;code&gt;Timer&lt;/code&gt; for the display updates. Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It syncs to the screen refresh rate.&lt;/strong&gt; No point updating faster than the display can render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;.common&lt;/code&gt; run loop mode&lt;/strong&gt; means it keeps firing during scrolling and other UI interactions. A default-mode Timer pauses when the user touches the screen -- terrible for a workout app.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Remaining Subtlety
&lt;/h2&gt;

&lt;p&gt;Even with absolute time anchoring, there is a display quantization issue. If your UI shows whole seconds, you need to decide: do you show the ceiling or the floor? I chose ceiling. When &lt;code&gt;remainingSeconds&lt;/code&gt; is 2.3, the display shows "3". This means the display changes from "1" to "0" right as time actually expires, which feels correct to users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;displaySeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingSeconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;After this refactor, BoxTime's timer is accurate to within a single frame (~16ms at 60fps). Over a 36-minute session, the cumulative error is zero because there is no accumulation -- every frame recalculates from the absolute endpoint.&lt;/p&gt;

&lt;p&gt;If you are building any kind of countdown or stopwatch in SwiftUI, anchor to real time. Never count ticks. Your users might not notice 2 seconds of drift, but they will notice 10. And if they are getting punched in the face, they will definitely notice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-D9D3A8E7" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt; is free on the App Store if you want to see this in action.&lt;/p&gt;

</description>
      <category>swift</category>
    </item>
    <item>
      <title>App Store Optimization for Niche Fitness Apps</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Mon, 23 Mar 2026 07:39:39 +0000</pubDate>
      <link>https://dev.to/lalo132/app-store-optimization-for-niche-fitness-apps-ng</link>
      <guid>https://dev.to/lalo132/app-store-optimization-for-niche-fitness-apps-ng</guid>
      <description>&lt;h2&gt;
  
  
  ASO When Nobody Is Searching for You
&lt;/h2&gt;

&lt;p&gt;App Store Optimization for a mainstream fitness app is well-documented. But what about a niche app like BoxTime, a boxing round timer? The total search volume for "boxing timer" is a fraction of "workout app." Here is what I learned optimizing for a small but specific audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyword Research on a Budget
&lt;/h2&gt;

&lt;p&gt;I did not pay for an ASO tool. Instead, I used App Store Connect's search ads feature (even without running ads) to gauge keyword popularity, and I manually searched the App Store to see what competitors rank for.&lt;/p&gt;

&lt;p&gt;My primary keywords:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;boxing timer&lt;/li&gt;
&lt;li&gt;round timer&lt;/li&gt;
&lt;li&gt;boxing round timer&lt;/li&gt;
&lt;li&gt;fight timer&lt;/li&gt;
&lt;li&gt;MMA timer&lt;/li&gt;
&lt;li&gt;interval timer boxing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The long-tail matters more in a niche. "Interval timer" is competitive -- massive apps dominate it. "Boxing round timer" has less volume but almost zero competition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Title and Subtitle Strategy
&lt;/h2&gt;

&lt;p&gt;Apple gives you 30 characters for the title and 30 for the subtitle. Every character counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Title:    BoxTime - Boxing Timer
Subtitle: Round Timer for Training
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I front-loaded the brand name, included the primary keyword ("Boxing Timer"), and used the subtitle for a secondary keyword phrase. I avoid keyword stuffing -- Apple penalizes it, and it looks unprofessional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Description Nobody Reads (But Apple Does)
&lt;/h2&gt;

&lt;p&gt;The long description does not directly impact search ranking (Apple says keywords are what matter), but it matters for conversion. Users who scroll to the description are on the fence. Bullet points work. Walls of text do not.&lt;/p&gt;

&lt;p&gt;I structure it as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One-line value proposition&lt;/li&gt;
&lt;li&gt;Three key features as bullets&lt;/li&gt;
&lt;li&gt;Brief paragraph about who it is for&lt;/li&gt;
&lt;li&gt;Call to action&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Screenshots That Convert
&lt;/h2&gt;

&lt;p&gt;For a timer app, the screenshots need to show the timer in action, not a features list. My screenshot strategy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timer running&lt;/strong&gt; -- the hero shot, big countdown, clear round indicator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt; -- show how simple setup is&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rest phase&lt;/strong&gt; -- demonstrate the round/rest cycle visually&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature callout&lt;/strong&gt; -- haptics, audio, customization in one frame&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I designed these in Figma with device frames. The first screenshot gets 70%+ of the attention, so it must immediately communicate what the app does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ratings and Reviews
&lt;/h2&gt;

&lt;p&gt;With a small user base, every rating matters disproportionately. A single 1-star review on an app with 10 ratings tanks your average. I use &lt;code&gt;SKStoreReviewController&lt;/code&gt; to prompt for ratings, but timing matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;checkForReviewPrompt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;completedWorkouts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;lastReviewPrompt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastReviewPrompt&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connectedScenes&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activationState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foregroundActive&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;UIWindowScene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;SKStoreReviewController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;lastReviewPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prompt after 5 completed workouts. At that point, the user has gotten value from the app and is likely to leave a positive review. Prompting on first launch is a recipe for low ratings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localization as ASO
&lt;/h2&gt;

&lt;p&gt;I localized the App Store listing (not the app itself initially, just the metadata) into Spanish, Portuguese, and Japanese -- three markets with strong boxing cultures. Each localization gives you a new set of keyword slots. My downloads from Brazil doubled after adding Portuguese metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Moved the Needle
&lt;/h2&gt;

&lt;p&gt;Honestly? The biggest impact came from:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choosing keywords with low competition rather than high volume&lt;/li&gt;
&lt;li&gt;The first App Store screenshot&lt;/li&gt;
&lt;li&gt;Rating prompt timing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else was incremental. For a niche app like &lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-36320560" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt;, discoverability comes more from being the best result for a specific query than from ranking for broad terms.&lt;/p&gt;

</description>
      <category>indiedev</category>
    </item>
    <item>
      <title>Building a Breathing App — Why Simplicity Was the Hardest Part</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 19:38:50 +0000</pubDate>
      <link>https://dev.to/lalo132/building-a-breathing-app-why-simplicity-was-the-hardest-part-1lb0</link>
      <guid>https://dev.to/lalo132/building-a-breathing-app-why-simplicity-was-the-hardest-part-1lb0</guid>
      <description>&lt;h2&gt;
  
  
  The Trap of Feature Creep
&lt;/h2&gt;

&lt;p&gt;When I started building &lt;a href="https://socialhub-links.darian-hanci.workers.dev/lunair?ref=devto-AFBDAC66" rel="noopener noreferrer"&gt;Lunair&lt;/a&gt;, my breathing exercise app for iOS, I had a Notion board full of ideas. Guided meditations, social sharing, streaks, gamification, AI-powered breathing recommendations. The works.&lt;/p&gt;

&lt;p&gt;Six weeks in, I had a bloated prototype that did everything poorly and nothing well. The core experience — helping someone breathe — was buried under menus and onboarding flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripping It Down
&lt;/h2&gt;

&lt;p&gt;The turning point was watching someone use the app for the first time. They opened it, stared at the home screen for ten seconds, then asked "where do I start?" That was the moment I realized I had failed at the one thing that mattered.&lt;/p&gt;

&lt;p&gt;I spent the next two weeks removing features. It was painful. Every feature I cut represented hours of work. But each removal made the app feel lighter, more focused.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: A sprawling navigation structure&lt;/span&gt;
&lt;span class="kt"&gt;TabView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;HomeView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;ExploreView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;StatsView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;ProfileView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;SettingsView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: One screen, one purpose&lt;/span&gt;
&lt;span class="kt"&gt;BreathingView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;selectedPattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What "Simple" Actually Means in Code
&lt;/h2&gt;

&lt;p&gt;Simple UI does not mean simple code. In fact, the opposite is usually true. When you strip away navigation chrome, every pixel has to earn its place. The breathing animation needed to feel organic, not mechanical. The timing had to be precise enough to guide but forgiving enough to not stress someone out.&lt;/p&gt;

&lt;p&gt;I spent more time on the easing curve of a single circle animation than I did on entire view controllers in previous projects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;BreathingCircle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Circle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;breathingGradient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scaleEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;easeInOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inhaleDuration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Paradox of Minimal Design
&lt;/h2&gt;

&lt;p&gt;Here is what nobody tells you about minimal apps: users notice every flaw. In a complex app, a slightly off animation or an awkward transition gets lost in the noise. In a minimal app, it is the entire experience.&lt;/p&gt;

&lt;p&gt;I rewrote the main breathing view four times. Not because it was broken, but because "good enough" felt jarring in a context where there was nothing else to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with the core interaction, not the feature list.&lt;/strong&gt; If your app's primary action does not feel great in isolation, no amount of features will save it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity is a continuous practice.&lt;/strong&gt; Every update, I fight the urge to add "just one more thing."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure by what you remove.&lt;/strong&gt; The best sessions I had building Lunair were the ones where the line count went down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building a simple app taught me more about software craft than any complex project I have shipped. The constraint forced better decisions at every level — architecture, design, and user experience.&lt;/p&gt;

&lt;p&gt;If you are building something in the health or wellness space, resist the urge to compete on features. Compete on feel.&lt;/p&gt;

</description>
      <category>showdev</category>
    </item>
    <item>
      <title>Haptic Feedback Design for Workout Apps</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 17:09:32 +0000</pubDate>
      <link>https://dev.to/lalo132/haptic-feedback-design-for-workout-apps-48h5</link>
      <guid>https://dev.to/lalo132/haptic-feedback-design-for-workout-apps-48h5</guid>
      <description>&lt;h2&gt;
  
  
  Why Haptics Matter More Than Sound in the Gym
&lt;/h2&gt;

&lt;p&gt;When I built BoxTime, I assumed the bell sound would be the primary way users know a round ended. Then I tested it at an actual boxing gym. Between the music, the bag noise, other people training -- you cannot hear your phone. Haptics became the real signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Haptic Engine on iOS
&lt;/h2&gt;

&lt;p&gt;Apple gives you three levels of haptic control, from simple to granular:&lt;/p&gt;

&lt;h3&gt;
  
  
  UIImpactFeedbackGenerator
&lt;/h3&gt;

&lt;p&gt;The simplest option. Predefined impact styles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;impact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIImpactFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heavy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;impact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;impact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  UINotificationFeedbackGenerator
&lt;/h3&gt;

&lt;p&gt;Semantic feedback for success, warning, and error states.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UINotificationFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notificationOccurred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Core Haptics (CHHapticEngine)
&lt;/h3&gt;

&lt;p&gt;Full control over haptic patterns. This is where it gets interesting for a timer app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;CoreHaptics&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;HapticManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CHHapticEngine&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;prepareEngine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;CHHapticEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capabilitiesForHardware&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supportsHaptics&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;CHHapticEngine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Haptic engine failed: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;playRoundEndPattern&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Three strong taps with decreasing intervals - feels like a boxing bell&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CHHapticEvent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="kt"&gt;CHHapticEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticTransient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticIntensity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticSharpness&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="kt"&gt;CHHapticEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticTransient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticIntensity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticSharpness&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="kt"&gt;CHHapticEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticTransient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticIntensity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticSharpness&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;CHHapticPattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makePlayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;player&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;atTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CHHapticTimeImmediate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Designing a Haptic Language
&lt;/h2&gt;

&lt;p&gt;In BoxTime, I use different haptic patterns for different events, so the user can feel what is happening without looking at the screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Round start&lt;/strong&gt;: Two sharp taps (get ready, fight)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round end&lt;/strong&gt;: Three descending taps (imitates a bell resonating)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10-second warning&lt;/strong&gt;: Single soft pulse (heads up)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workout complete&lt;/strong&gt;: Long success pattern (celebration feel)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight is that haptic patterns should be distinct enough to be recognized without visual context. If your round-start and round-end patterns feel similar, the user has to look at the screen to know what happened, which defeats the purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .prepare() Call Matters
&lt;/h2&gt;

&lt;p&gt;Haptic engines have a spin-up latency. If you create a generator and immediately fire it, there may be a perceptible delay. Always call &lt;code&gt;.prepare()&lt;/code&gt; ahead of time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: latency on first fire&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;roundEnding&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;impact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIImpactFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heavy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;impact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// might be delayed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Good: pre-warm the engine&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;roundStarted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;upcomingHaptic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// warm up for the end-of-round haptic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;roundEnding&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;upcomingHaptic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// fires immediately&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In BoxTime, I prepare the next haptic event as soon as the current phase starts. By the time the round ends, the engine is ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Realities
&lt;/h2&gt;

&lt;p&gt;The iOS Simulator does not support haptics. You must test on a real device. And not all devices support Core Haptics -- the iPhone 7 supports basic UIFeedbackGenerator haptics but not the full CHHapticEngine. Always check &lt;code&gt;CHHapticEngine.capabilitiesForHardware().supportsHaptics&lt;/code&gt; and fall back gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Haptics are not a nice-to-have in fitness apps. They are essential. A boxer with gloves on cannot easily tap their phone screen. They need to feel the transitions. Investing time in distinct, well-timed haptic patterns was one of the highest-impact improvements I made to &lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-AC606967" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>swift</category>
    </item>
    <item>
      <title>Designing a Minimal UI — What I Removed and Why</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 14:57:04 +0000</pubDate>
      <link>https://dev.to/lalo132/designing-a-minimal-ui-what-i-removed-and-why-ac2</link>
      <guid>https://dev.to/lalo132/designing-a-minimal-ui-what-i-removed-and-why-ac2</guid>
      <description>&lt;h2&gt;
  
  
  The First Version Had Too Much
&lt;/h2&gt;

&lt;p&gt;The first version of BoxTime had a tab bar, a settings gear icon, a history view, round statistics, a customizable color picker, and an animated boxing glove mascot. It was a mess. I know this because nobody used it for more than 30 seconds before closing it.&lt;/p&gt;

&lt;p&gt;A boxing timer needs to do one thing: tell you when to fight and when to rest. Everything else is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Cut
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Tab Bar
&lt;/h3&gt;

&lt;p&gt;Three tabs: Timer, History, Settings. The History tab tracked past workouts. Nobody asked for this. I was building it because other apps had it, not because my users needed it. Removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Round Statistics
&lt;/h3&gt;

&lt;p&gt;Average round time, total training time this week, a little chart. Cool engineering, zero value for the core use case. The person using this app has boxing gloves on. They are not analyzing charts. Removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Color Picker
&lt;/h3&gt;

&lt;p&gt;Custom accent colors. I spent a day building a nice HSB color picker. One of my testers said "why would I change the color?" Fair point. Removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Complex Onboarding
&lt;/h3&gt;

&lt;p&gt;A three-screen onboarding flow explaining what a round timer is. If someone downloaded a boxing timer app, they know what a round timer is. Removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stayed
&lt;/h2&gt;

&lt;p&gt;After the purge, BoxTime has essentially two screens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt;: Set rounds, round duration, rest duration. Start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timer&lt;/strong&gt;: Big countdown number, current round indicator, start/pause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is it. The configuration uses simple steppers and pickers. The timer screen is dominated by the countdown -- large enough to read from across the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Design Principles I Followed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Glanceability
&lt;/h3&gt;

&lt;p&gt;During a workout, you should understand the app's state in under one second. Big numbers, high contrast, minimal text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;font&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;design&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;monospaced&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;foregroundStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;primary&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;green&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Reachability
&lt;/h3&gt;

&lt;p&gt;Everything interactive is in the bottom half of the screen. During a workout, you are holding your phone in one hand (or it is on a shelf at eye level). Tap targets are large.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Progressive Disclosure
&lt;/h3&gt;

&lt;p&gt;Settings exist, but they are not visible during a workout. Configuration happens before you start. Once the timer is running, the only controls are pause and stop.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. State Through Color
&lt;/h3&gt;

&lt;p&gt;Instead of labels that say "ROUND" or "REST," the entire screen color shifts. You do not need to read text to know which phase you are in. Your peripheral vision catches the color change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;black&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;systemGreen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;systemRed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Hard Part Is Saying No
&lt;/h2&gt;

&lt;p&gt;Every feature I cut was something I enjoyed building. The history view had a nice SwiftUI chart. The color picker used some clever geometry. But features are not free -- every one adds cognitive load, maintenance burden, and potential bugs.&lt;/p&gt;

&lt;p&gt;The version of &lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-20A265C9" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt; on the App Store today is maybe 30% of the code I originally wrote. The other 70% made the app worse by making it more complex.&lt;/p&gt;

&lt;p&gt;If you are building a utility app, start by listing every feature. Then remove half of them. Then remove half of what remains. What is left is probably your app.&lt;/p&gt;

</description>
      <category>swiftui</category>
    </item>
    <item>
      <title>Marketing a Mental Health App With Zero Budget</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 12:35:08 +0000</pubDate>
      <link>https://dev.to/lalo132/marketing-a-mental-health-app-with-zero-budget-4bnb</link>
      <guid>https://dev.to/lalo132/marketing-a-mental-health-app-with-zero-budget-4bnb</guid>
      <description>&lt;h2&gt;
  
  
  The Cold Reality
&lt;/h2&gt;

&lt;p&gt;I launched &lt;a href="https://socialhub-links.darian-hanci.workers.dev/lunair?ref=devto-EAD8E04F" rel="noopener noreferrer"&gt;Lunair&lt;/a&gt; with exactly $0 in marketing budget. No paid ads, no influencer deals, no PR agency. Just me, a shipped app, and the internet.&lt;/p&gt;

&lt;p&gt;Six months later, the app has organic downloads that continue to grow. Here is what actually worked and what was a complete waste of time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Worked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dev Community Content
&lt;/h3&gt;

&lt;p&gt;Writing about the technical process of building Lunair brought more downloads than any other single strategy. Posts on Dev.to, Hacker News, and indie dev communities generated genuine interest because they provided value beyond "download my app."&lt;/p&gt;

&lt;p&gt;The key was leading with the technical insight and letting the app be the context, not the pitch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How I built custom haptic patterns for a breathing app" works&lt;/li&gt;
&lt;li&gt;"Check out my new breathing app" does not&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reddit (Carefully)
&lt;/h3&gt;

&lt;p&gt;Reddit hates self-promotion and can smell it immediately. What works is genuine participation in relevant communities — r/SwiftUI, r/iOSProgramming, r/IndieGaming — where I shared specific technical solutions. My flair mentioned Lunair, and interested people clicked through.&lt;/p&gt;

&lt;p&gt;I never posted "I made this app." I posted solutions to problems other devs were having, using my app as an example.&lt;/p&gt;

&lt;h3&gt;
  
  
  App Store Optimization
&lt;/h3&gt;

&lt;p&gt;ASO was the highest-ROI activity by far. I spent two weeks researching keywords, rewriting my app description, and iterating on screenshots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title:&lt;/strong&gt; Include your primary keyword naturally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subtitle:&lt;/strong&gt; Your second most important keyword phrase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keywords field:&lt;/strong&gt; All 100 characters, no spaces after commas, no duplicating words from the title&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots:&lt;/strong&gt; The first two screenshots decide everything; they need to communicate the core value in under 2 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Twitter/X Indie Dev Community
&lt;/h3&gt;

&lt;p&gt;The #IndieDev and #SwiftUI communities on Twitter are genuinely supportive. I posted build progress, design decisions, and small wins. The engagement was modest but highly targeted — people who follow these hashtags are either developers or tech-curious users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Product Hunt Launch
&lt;/h3&gt;

&lt;p&gt;Lunair's Product Hunt launch brought a spike of about 200 downloads in 48 hours. Not life-changing, but those early users provided invaluable feedback and several became repeat users who left reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Did Not Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cold Outreach to Press
&lt;/h3&gt;

&lt;p&gt;I emailed 30 app review sites. Two responded. Zero published anything. Small indie apps without a hook beyond "it is well-made" do not get coverage. The effort-to-result ratio was terrible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Social Media Ads (Small Test)
&lt;/h3&gt;

&lt;p&gt;I ran a $50 test on Instagram. The CPM was reasonable but the conversion was near zero. Health app ads compete with massive companies spending millions. My $50 was invisible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature Request Sites
&lt;/h3&gt;

&lt;p&gt;Submitting to "app of the day" aggregator sites generated zero measurable impact. Most of these sites have minimal real traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compounding Strategy
&lt;/h2&gt;

&lt;p&gt;The real insight is that everything I did was compounding content. Dev articles stay indexed. Reddit answers stay searchable. ASO improvements work every single day. Unlike ads that stop working when you stop paying, content keeps generating returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers I Will Share
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Total marketing spend: $50 (the failed Instagram test)&lt;/li&gt;
&lt;li&gt;Highest single-day downloads came from a Hacker News comment, not a post&lt;/li&gt;
&lt;li&gt;ASO changes increased organic search traffic by roughly 40%&lt;/li&gt;
&lt;li&gt;Average time from "discovers app" to "downloads" was under 24 hours — people who found Lunair through content were pre-qualified&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advice for Indie Devs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Your development process is your marketing content&lt;/li&gt;
&lt;li&gt;Provide value first; mention your app second&lt;/li&gt;
&lt;li&gt;Invest heavily in ASO — it is the only "free" channel that scales&lt;/li&gt;
&lt;li&gt;Be patient. Organic growth is slow but durable&lt;/li&gt;
&lt;li&gt;Every user who finds you through content is worth ten from ads&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Zero budget marketing is not free. You pay with time and consistency. But for indie devs, time is the one resource we actually have.&lt;/p&gt;

</description>
      <category>showdev</category>
    </item>
    <item>
      <title>Managing App State With SwiftUI @Observable</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 12:15:08 +0000</pubDate>
      <link>https://dev.to/lalo132/managing-app-state-with-swiftui-observable-3icd</link>
      <guid>https://dev.to/lalo132/managing-app-state-with-swiftui-observable-3icd</guid>
      <description>&lt;h2&gt;
  
  
  From ObservableObject to &lt;a class="mentioned-user" href="https://dev.to/observable"&gt;@observable&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;When I started BoxTime, I used &lt;code&gt;ObservableObject&lt;/code&gt; with &lt;code&gt;@Published&lt;/code&gt; properties everywhere. It worked, but the boilerplate was annoying and the performance was worse than it needed to be. Then iOS 17 shipped &lt;code&gt;@Observable&lt;/code&gt;, and I migrated everything in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Way
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pre-iOS 17&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;currentRound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimerPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isRunning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;totalRounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;roundDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;restDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@StateObject&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// or @ObservedObject, @EnvironmentObject...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: every time any &lt;code&gt;@Published&lt;/code&gt; property changed, every view observing the object re-evaluated its body. If &lt;code&gt;remainingTime&lt;/code&gt; updated 10 times per second, every view depending on &lt;code&gt;TimerManager&lt;/code&gt; re-rendered 10 times per second -- even views that only read &lt;code&gt;totalRounds&lt;/code&gt;, which almost never changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Way
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;currentRound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimerPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isRunning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;totalRounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;roundDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;restDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// This view only re-renders when remainingTime or phase change&lt;/span&gt;
        &lt;span class="c1"&gt;// because those are the only properties accessed in body&lt;/span&gt;
        &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@Observable&lt;/code&gt; macro automatically tracks which properties each view reads during its &lt;code&gt;body&lt;/code&gt; evaluation. Only changes to accessed properties trigger re-renders. This is per-view granular observation, and it is a massive performance win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Architecture in BoxTime
&lt;/h2&gt;

&lt;p&gt;BoxTime has a few observable objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;currentRound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimerPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;

    &lt;span class="c1"&gt;// Configuration (rarely changes)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;WorkoutConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// Computed properties work automatically&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;displayTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remainingTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"%d:%02d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startWorkout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;
        &lt;span class="n"&gt;remainingTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;roundDuration&lt;/span&gt;
        &lt;span class="nf"&gt;startTimer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;WorkoutConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;roundDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;restDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I separated &lt;code&gt;WorkoutConfig&lt;/code&gt; from &lt;code&gt;TimerManager&lt;/code&gt; because configuration is edited on one screen and consumed on another. Keeping them separate means the configuration view does not observe timer ticks, and the timer view does not care about config edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment Injection
&lt;/h2&gt;

&lt;p&gt;Passing observable objects through the environment is cleaner with &lt;code&gt;@Observable&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@main&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;BoxTimeApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;App&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timerManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;TimerManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;Scene&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;WindowGroup&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;ContentView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timerManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TimerManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;displayTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more &lt;code&gt;@EnvironmentObject&lt;/code&gt; -- just &lt;code&gt;@Environment&lt;/code&gt; with the type. And you get compile-time type safety instead of runtime crashes when the object is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Gotcha: Bindings
&lt;/h2&gt;

&lt;p&gt;Creating bindings to &lt;code&gt;@Observable&lt;/code&gt; properties in SwiftUI requires &lt;code&gt;@Bindable&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ConfigView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;TimerManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;@Bindable&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt;
        &lt;span class="kt"&gt;Stepper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Rounds: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;$timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tripped me up during migration. The &lt;code&gt;@Bindable&lt;/code&gt; wrapper must be declared inside &lt;code&gt;body&lt;/code&gt; (or the property must be &lt;code&gt;@Bindable&lt;/code&gt; at the struct level).&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Effort
&lt;/h2&gt;

&lt;p&gt;Migrating BoxTime from &lt;code&gt;ObservableObject&lt;/code&gt; to &lt;code&gt;@Observable&lt;/code&gt; took about 3 hours. Most of it was removing &lt;code&gt;@Published&lt;/code&gt;, &lt;code&gt;@StateObject&lt;/code&gt;, and &lt;code&gt;@ObservedObject&lt;/code&gt; annotations. The result: less code, better performance, and more intuitive data flow.&lt;/p&gt;

&lt;p&gt;If you are building a new SwiftUI app today and targeting iOS 17+, there is no reason to use the old observation system. &lt;a href="https://socialhub-links.darian-hanci.workers.dev/boxtime?ref=devto-1FF0F4C8" rel="noopener noreferrer"&gt;BoxTime&lt;/a&gt; runs noticeably smoother after the migration, especially on the timer screen where updates happen many times per second.&lt;/p&gt;

</description>
      <category>swiftui</category>
    </item>
    <item>
      <title>Haptic Feedback Patterns for Eyes-Free UX</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sun, 22 Mar 2026 08:55:20 +0000</pubDate>
      <link>https://dev.to/lalo132/haptic-feedback-patterns-for-eyes-free-ux-3ej1</link>
      <guid>https://dev.to/lalo132/haptic-feedback-patterns-for-eyes-free-ux-3ej1</guid>
      <description>&lt;h2&gt;
  
  
  Why Haptics Matter More Than You Think
&lt;/h2&gt;

&lt;p&gt;When I watch people use &lt;a href="https://socialhub-links.darian-hanci.workers.dev/lunair?ref=devto-414FF691" rel="noopener noreferrer"&gt;Lunair&lt;/a&gt;, something interesting happens. After a minute or two of following the breathing animation, most users close their eyes. Makes sense — you are trying to relax. But the moment they close their eyes, they lose the visual guide.&lt;/p&gt;

&lt;p&gt;This is where haptics become the primary interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Haptic Vocabulary
&lt;/h2&gt;

&lt;p&gt;Apple's haptic engine is surprisingly expressive if you treat it as a design language rather than a notification mechanism. In Lunair, I developed a small vocabulary of haptic patterns that users learn intuitively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;BreathingHapticEngine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;softGenerator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIImpactFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;soft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;lightGenerator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIImpactFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;light&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rigidGenerator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIImpactFeedbackGenerator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rigid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;prepareAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;softGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;lightGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;rigidGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;signalInhaleStart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;softGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;signalExhaleStart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lightGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;signalSessionComplete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enumerated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asyncAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rigidGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactOccurred&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nv"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: inhale gets a softer, slightly stronger tap. Exhale gets a lighter, gentler tap. Your brain maps these to the actions after just two or three breathing cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing Generators Ahead of Time
&lt;/h2&gt;

&lt;p&gt;One of the most common haptic mistakes in iOS development is not preparing your feedback generators. There is a perceptible delay between creating a generator and it being ready to fire. In a breathing app where timing is everything, that delay breaks the experience:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Called when a breathing session starts&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;sessionWillBegin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hapticEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepareAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// Generators are now "spun up" and ready&lt;/span&gt;
    &lt;span class="c1"&gt;// First haptic will fire with zero delay&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prepare generators at session start rather than at first use. The Taptic Engine stays warm for about 2 seconds after preparation, so for continuous use during a breathing session, you really only need to prepare once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Haptics for Advanced Patterns
&lt;/h2&gt;

&lt;p&gt;For the premium breathing patterns in Lunair, I use Core Haptics instead of the basic UIFeedbackGenerator. This lets me create continuous haptic textures that can ramp up and down — matching the breathing curve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;createInhalePattern&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;CHHapticPattern&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rampUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CHHapticParameterCurve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticIntensityControl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;controlPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CHHapticEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticContinuous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="kt"&gt;CHHapticEventParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;parameterID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hapticSharpness&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inhaleDuration&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;CHHapticPattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;parameterCurves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rampUp&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a haptic sensation that swells and fades with the breath — a gentle vibration that intensifies as lungs fill and softens as you approach the hold phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respecting User Preferences
&lt;/h2&gt;

&lt;p&gt;Not everyone wants haptics. Some users find them distracting. In Lunair, haptic intensity is adjustable from off to strong, and the setting persists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;HapticIntensity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CaseIterable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;moderate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strong&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;moderate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing Haptics
&lt;/h2&gt;

&lt;p&gt;You cannot test haptics in the simulator. This seems obvious, but I spent an embarrassing amount of time wondering why my haptic code "was not working" before remembering this. Always test on a physical device, and test with your eyes closed — that is the actual use case.&lt;/p&gt;

&lt;p&gt;Haptics turned out to be one of Lunair's most praised features. Users consistently mention the eyes-closed experience in reviews. It is a small investment that fundamentally changes how people interact with the app.&lt;/p&gt;

</description>
      <category>indiedev</category>
    </item>
    <item>
      <title>Data Privacy in Health Apps — What I Chose Not to Collect</title>
      <dc:creator>garry</dc:creator>
      <pubDate>Sat, 21 Mar 2026 10:19:39 +0000</pubDate>
      <link>https://dev.to/lalo132/data-privacy-in-health-apps-what-i-chose-not-to-collect-i98</link>
      <guid>https://dev.to/lalo132/data-privacy-in-health-apps-what-i-chose-not-to-collect-i98</guid>
      <description>&lt;h2&gt;
  
  
  The Default Is to Collect Everything
&lt;/h2&gt;

&lt;p&gt;When I set up analytics for &lt;a href="https://apps.apple.com/app/6759969887" rel="noopener noreferrer"&gt;Lunair&lt;/a&gt;, every tutorial and SDK I encountered was designed to maximize data collection. Session recordings, user journeys, demographic profiling, device fingerprinting. The default for modern app development is to hoover up everything and figure out what is useful later.&lt;/p&gt;

&lt;p&gt;For a mental health app, I decided to go the opposite direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lunair Does Not Collect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No personal health data leaves the device.&lt;/strong&gt; Breathing session history, pattern preferences, and usage frequency stay in local storage. Period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No user accounts.&lt;/strong&gt; No email, no sign-up, no social login. There is nothing to breach because there is nothing stored server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No location data.&lt;/strong&gt; I have no reason to know where someone is breathing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No third-party analytics SDKs.&lt;/strong&gt; No Firebase Analytics, no Mixpanel, no Amplitude. Every one of these sends data to third-party servers with their own privacy policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Do Collect (and Why)
&lt;/h2&gt;

&lt;p&gt;I use Apple's built-in App Analytics through App Store Connect. This gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download numbers&lt;/li&gt;
&lt;li&gt;Session counts (aggregate, not per-user)&lt;/li&gt;
&lt;li&gt;Crash reports&lt;/li&gt;
&lt;li&gt;Device type distribution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is enough to make informed product decisions without knowing anything about individual users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Implementation
&lt;/h2&gt;

&lt;p&gt;Keeping data local is surprisingly straightforward with SwiftUI and SwiftData:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Model&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;BreathingSession&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;patternId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;completedCycles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;patternId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;completedCycles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;patternId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;patternId&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;startDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;startDate&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completedCycles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;completedCycles&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All session data lives in the local SwiftData container. It syncs via iCloud if the user has it enabled — meaning the data goes to their iCloud account, not mine. I never see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Privacy Nutrition Label
&lt;/h2&gt;

&lt;p&gt;Apple's App Privacy labels forced me to think carefully about every data point. Lunair's label is almost empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Data Not Collected
- We do not collect any data from this app.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That label is a feature. Users see it before downloading and it builds immediate trust, especially in the health category where privacy concerns are elevated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Business Trade-Off
&lt;/h2&gt;

&lt;p&gt;I will not pretend there is no cost. Without detailed analytics, I cannot do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A/B testing of features&lt;/li&gt;
&lt;li&gt;Funnel analysis to optimize onboarding&lt;/li&gt;
&lt;li&gt;Cohort analysis for retention&lt;/li&gt;
&lt;li&gt;Personalized recommendations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real capabilities I am giving up. But for a breathing app, the trust equation tilts heavily toward privacy. Users open Lunair during their most vulnerable moments. The implicit promise is that those moments are private.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Make Product Decisions Without Data
&lt;/h2&gt;

&lt;p&gt;Without analytics, I rely on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;App Store reviews.&lt;/strong&gt; Qualitative feedback from people who care enough to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct email.&lt;/strong&gt; The app has a "Send Feedback" option that opens a pre-filled email. No tracking, just human communication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TestFlight beta testers.&lt;/strong&gt; A small group who provide detailed feedback on new features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own usage.&lt;/strong&gt; I use Lunair daily. If something bothers me, it probably bothers others.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The feedback mechanism is intentionally simple&lt;/span&gt;
&lt;span class="kt"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Send Feedback"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"mailto:feedback@example.com?subject=Lunair%20Feedback"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Advice for Health App Developers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default to not collecting.&lt;/strong&gt; Add data collection only when you have a specific, justified need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid third-party SDKs.&lt;/strong&gt; Each one is a privacy liability you cannot fully control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make privacy a feature.&lt;/strong&gt; Communicate it clearly in your App Store listing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-first architecture pays off.&lt;/strong&gt; It is simpler, faster, and eliminates an entire category of security concerns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best security for user health data is not having it in the first place.&lt;/p&gt;

</description>
      <category>swift</category>
    </item>
  </channel>
</rss>
