<?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: Yacine Zitani</title>
    <description>The latest articles on DEV Community by Yacine Zitani (@yacine_zitani_1a371a8b61e).</description>
    <link>https://dev.to/yacine_zitani_1a371a8b61e</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4225609%2Faba6a067-c8d1-4775-93d2-14ddf450e0b4.jpg</url>
      <title>DEV Community: Yacine Zitani</title>
      <link>https://dev.to/yacine_zitani_1a371a8b61e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yacine_zitani_1a371a8b61e"/>
    <language>en</language>
    <item>
      <title>Keeping a Flutter timer honest when the app goes to the background</title>
      <dc:creator>Yacine Zitani</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:44:51 +0000</pubDate>
      <link>https://dev.to/yacine_zitani_1a371a8b61e/keeping-a-flutter-timer-honest-when-the-app-goes-to-the-background-33hn</link>
      <guid>https://dev.to/yacine_zitani_1a371a8b61e/keeping-a-flutter-timer-honest-when-the-app-goes-to-the-background-33hn</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31h4kz49eyltjv9nnwr1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31h4kz49eyltjv9nnwr1.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A timer app has one job: be right. In Flutter the easy version is wrong the moment the user locks the phone.&lt;/p&gt;

&lt;p&gt;I build Flip Timer, a Pomodoro timer with a flip-clock display. This is how I made it keep correct time when the OS suspends the app. The snippets are trimmed from the real code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The obvious design is a &lt;code&gt;Timer.periodic&lt;/code&gt; that ticks once a second and adds a second to &lt;code&gt;elapsed&lt;/code&gt;. It works while the app is in the foreground. Both iOS and Android suspend the process shortly after the app goes to the background, so the ticks stop. Come back after 10 minutes and a naive timer still says 24:50.&lt;/p&gt;

&lt;p&gt;Two things are needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When the app comes back, work out how much time really passed.&lt;/li&gt;
&lt;li&gt;While the app is gone, still tell the user when the session ends.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: store the end time, not the elapsed time
&lt;/h2&gt;

&lt;p&gt;When a session starts, save the wall-clock time it should end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;_sessionEndAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;_startTicker&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Timer.periodic, only for updating the UI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ticker now only redraws the digits. The end time is the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: reconcile when the app resumes
&lt;/h2&gt;

&lt;p&gt;Flutter's &lt;code&gt;AppLifecycleListener&lt;/code&gt; tells you when the app is back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;_lifecycleListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppLifecycleListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;onStateChange:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_lifecycleState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appState&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;AppLifecycleState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;_reconcile&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;&lt;code&gt;_reconcile()&lt;/code&gt; compares the stored end time with &lt;code&gt;DateTime.now()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_reconcile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRunning&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;_sessionEndAt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;endAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_sessionEndAt&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endAt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Still inside the session: just correct the displayed time.&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;elapsed:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;endAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&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="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// The session finished while we were away: record it with its REAL end time.&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 user sees the correct remaining time even though no tick ran in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: be careful when the session ended while away
&lt;/h2&gt;

&lt;p&gt;If auto-start is on, several sessions might have finished while the app was suspended. My code replays them in a loop and records each one with its own real end time (not "now"), so stats and streaks stay correct.&lt;/p&gt;

&lt;p&gt;I also put two limits on this, because a bug here would invent focus time that never happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_maxReconciledSessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;_maxReconcileWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;hours:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user was away more than 4 hours, the app credits only the one session that genuinely finished and stops. A week-long absence with auto-start must not create hundreds of fake sessions in the heatmap.&lt;/p&gt;

&lt;p&gt;Lesson: when you rebuild history from a clock, set a ceiling on how much you are willing to believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: tell the user while the app is suspended
&lt;/h2&gt;

&lt;p&gt;Reconciling only helps when the user returns. To signal the end of a session while the app is frozen, I schedule a local notification for the end time, using &lt;code&gt;flutter_local_notifications&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduledTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
      &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&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 title is "Session complete" or "Break over". After a reconcile, the notification is scheduled again for the new end time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: exact alarms can fail, so fall back
&lt;/h2&gt;

&lt;p&gt;On Android, exact alarms need a permission the user can deny or revoke. So the scheduling call is wrapped: try exact, and if it throws, retry with inexact scheduling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inexactAllowWhileIdle&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;An inexact alarm may fire a bit late. A late notification still beats none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: screen on and fullscreen
&lt;/h2&gt;

&lt;p&gt;A timer on a desk shouldn't dim. I use &lt;code&gt;wakelock_plus&lt;/code&gt; for keep-screen-on (a setting, on by default) and a sticky immersive mode for the fullscreen view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;WakelockPlus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;enable:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keepScreenOn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;SystemChrome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEnabledSystemUIMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SystemUiMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;immersiveSticky&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;Honesty section: nothing counts down in the background. The app is suspended, and the timer is reconstructed from the clock when you return. What you get while away is the scheduled notification, and its sound is the system default, not the alarm sound you pick inside the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store the end time, not the elapsed time.&lt;/li&gt;
&lt;li&gt;Reconcile on &lt;code&gt;AppLifecycleState.resumed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Cap how much time you are willing to reconstruct.&lt;/li&gt;
&lt;li&gt;Schedule an OS notification for the end of the session.&lt;/li&gt;
&lt;li&gt;Expect exact alarms to fail and have a fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see it running: &lt;a href="https://www.fliptimer.online/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=global_distribution_2026&amp;amp;utm_content=dv01" rel="noopener noreferrer"&gt;Flip Timer&lt;/a&gt; is free and supported by ads, and available on Android. I'd like to hear how you handled background timing in your own apps.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>android</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
