Skip to main content

Mobile Development7 min read

Build a Pomodoro Focus Timer in Flutter

Build a drift-free Pomodoro timer in Flutter with local notifications for session transitions, persistent session history, and calm deep-work analytics.

  • #flutter
  • #dart
  • #pomodoro
  • #productivity
  • #timer
Build a Pomodoro Focus Timer in Flutter
Advertisement

I built ZenPeace: Focus Pomodoro Timer, a minimalist all-in-one focus companion built around the Pomodoro Technique. It ships customizable focus intervals with the classic 25/5 presets and fully custom work and break lengths, a daily task planner with urgency levels and pomodoro intervals assigned directly to each task, deep-work analytics with daily, weekly and monthly charts plus full session history, calming ambient sounds, and a distraction-free design that works completely offline. The case study is here: ZenPeace: Focus Pomodoro Timer. A timer sounds like a weekend project until you ship one. The first version of ZenPeace did what every tutorial does: a Timer.periodic counting down from 25 minutes. Then the bug reports arrived. The countdown froze when the screen turned off. It drifted by minutes over a long focus session. The break alert never fired because the OS had already killed the app. And the analytics page was a wall of fake data, because nothing had been persisted correctly. Building a pomodoro timer that survives real-world use is an exercise in respecting how mobile operating systems actually treat your app. Here is the full pattern that shipped in production.

A periodic timer drifts — build on elapsed time instead

Timer.periodic(Duration(seconds: 1), ...) is the classic trap. It is not a clock; it is a scheduling request, and the event loop can delay each tick. Over 25 minutes, those delays add up, and your "25-minute" session can be 27 minutes long. Worse, when the OS throttles your app in the background, ticks stop entirely and the countdown silently freezes. The fix is to stop counting ticks and start measuring elapsed time against a fixed end point. Set _endsAt = DateTime.now().add(duration) when the session starts, and derive the remaining time from the wall clock on every frame:

class PomodoroEngine extends ChangeNotifier {
  PomodoroEngine({required TickerProvider vsync}) {
    _ticker = vsync.createTicker(_onTick);
  }

  late final Ticker _ticker;
  PomodoroPhase phase = PomodoroPhase.idle;
  DateTime? _endsAt;

  Duration get remaining {
    final endsAt = _endsAt;
    if (phase == PomodoroPhase.idle || endsAt == null) {
      return Duration.zero;
    }
    final left = endsAt.difference(DateTime.now());
    return left.isNegative ? Duration.zero : left;
  }

  void _onTick(Duration elapsed) {
    if (remaining == Duration.zero) _advance();
    notifyListeners();
  }
}

A Ticker is the right driver here because it only fires while the app is in the foreground and your widget tree is mounted — exactly when you need to repaint the countdown. It also respects the widget lifecycle, so there is nothing to leak if the user navigates away. For a deeper look at the async primitives behind these patterns, see Dart Streams vs Futures in Flutter.

Model sessions as a state machine

A pomodoro timer is not one timer; it is a cycle: focus, short break, focus, short break, and every fourth focus session earns a long break. Encoding that as a state machine keeps the transitions testable and keeps the UI logic dumb:

Advertisement
enum PomodoroPhase { idle, focus, shortBreak, longBreak }

class PomodoroEngine extends ChangeNotifier {
  Duration focusLength = const Duration(minutes: 25);
  Duration shortBreakLength = const Duration(minutes: 5);
  Duration longBreakLength = const Duration(minutes: 15);
  int completedFocusSessions = 0;

  Duration _phaseDuration(PomodoroPhase phase) {
    switch (phase) {
      case PomodoroPhase.focus:
        return focusLength;
      case PomodoroPhase.shortBreak:
        return shortBreakLength;
      case PomodoroPhase.longBreak:
        return longBreakLength;
      case PomodoroPhase.idle:
        return Duration.zero;
    }
  }

  void start() {
    if (phase == PomodoroPhase.idle) phase = PomodoroPhase.focus;
    _endsAt = DateTime.now().add(_phaseDuration(phase));
    scheduleSessionAlert(
      id: phase.hashCode,
      when: _endsAt!,
      title: phase == PomodoroPhase.focus
          ? 'Focus session complete'
          : 'Break over — back to it',
      body: 'Take a breath. Your next phase is ready.',
    );
    _ticker.start();
    notifyListeners();
  }

  void _advance() {
    _ticker.stop();
    if (phase == PomodoroPhase.focus) {
      completedFocusSessions++;
      phase = completedFocusSessions % 4 == 0
          ? PomodoroPhase.longBreak
          : PomodoroPhase.shortBreak;
    } else {
      phase = PomodoroPhase.focus;
    }
    recordSession(phase);
    start();
  }
}

Customizable durations are a core ZenPeace feature — the classic 25/5 presets are just defaults. Store the user's chosen lengths in local storage and restore them at startup, and validate the inputs (a 90-minute focus session is fine; a zero-minute break is not).

Survive screen-off and app kills with local notifications

Here is the hard truth about mobile timers: your Dart code stops running when the OS suspends or kills your app. No foreground service, no background isolate, and no amount of clever tick logic changes that on stock Android and iOS. ZenPeace works fully offline, so there is no server to send a push notification either. The production solution is to schedule the transition alert at the moment the session starts, using the OS-level scheduler — and then cancel or let it fire as needed. With flutter_local_notifications:

Future<void> scheduleSessionAlert({
  required int id,
  required DateTime when,
  required String title,
  required String body,
}) {
  return notifications.zonedSchedule(
    id,
    title,
    body,
    tz.TZDateTime.from(when, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'pomodoro',
        'Pomodoro alerts',
        importance: Importance.high,
      ),
    ),
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
  );
}

AndroidScheduleMode.exactAllowWhileIdle matters: without it, Doze mode on Android will happily delay your "focus complete" chime until the user unlocks their phone an hour later. If the user cancels a session early, call notifications.cancel(id) so a stale alert does not fire mid-meeting. The full notification setup, including exact-alarm permissions on Android 12+, is covered in Flutter Local Notifications: Full Guide. One nuance worth knowing: when the app is killed and restarted, the in-memory engine is gone, but the OS alert still fires. On cold start, rebuild the state from the scheduled alert's metadata — or simply present the idle screen and let the notification carry the message. Do not attempt to silently "resume" a session you cannot verify; showing an honest idle state beats a hallucinated timer.

Persist every session for deep-work analytics

ZenPeace's deep-work analytics — daily, weekly and monthly charts plus full session history — are only possible because every completed session is recorded the moment it ends. The record is tiny, so plain local storage is enough; a database is overkill for a few integers a day:

Future<void> recordSession(PomodoroPhase finishedPhase) async {
  final prefs = await SharedPreferences.getInstance();
  final day = DateTime.now().toIso8601String().substring(0, 10); // YYYY-MM-DD
  final key = 'sessions_$day';
  final raw = prefs.getStringList(key) ?? <String>[];
  raw.add(jsonEncode({
    'phase': finishedPhase.name,
    'endedAt': DateTime.now().toIso8601String(),
  }));
  await prefs.setStringList(key, raw);
}
Advertisement

From these records, aggregating analytics is straightforward: minutes focused per day for the daily chart, rolling 7-day totals for the weekly view, and per-day sums grouped by week or month for the monthly view. Compute the aggregates on read, not on write — the dataset is small enough that even a year of sessions aggregates in milliseconds, and deriving on read means a bug in your chart math never corrupts stored data. This is the same discipline as the task planner: the source of truth is the raw event log, never a cached total.

Tie tasks to pomodoros

A standalone timer is a toy; a timer wired to a task list is a productivity tool. ZenPeace's daily task planner assigns pomodoro intervals directly to tasks and tracks them per task, with urgency levels so the most important work gets its sessions first. Keep the model simple: a task has a title, an urgency level, a target number of pomodoros, and a completed count. Each time a focus session ends, attribute it to the currently selected task. The planner UI then answers the two questions users actually ask: "what should I work on now?" (sorted by urgency) and "how far along am I?" (completed versus target pomodoros, shown as a simple progress ring). Resist the temptation to add due dates, tags, subtasks, and projects in version one. ZenPeace deliberately ships a minimalist design engineered to reduce cognitive load, and the planner is no exception — every field you add is a field the user has to maintain instead of focusing.

Keep transitions gentle

A productivity timer should not shout at you. ZenPeace uses gentle session-transition alerts: a soft chime rather than an alarm klaxon. Since the app works fully offline, the chime ships as a bundled audio asset played with audioplayers — no network, no streaming, no buffering delay at the exact moment the break starts:

final player = AudioPlayer();

Future<void> playChime() async {
  await player.play(AssetSource('sounds/session-complete.mp3'));
}

Bundled-asset audio playback with preloading and cache control is covered in detail in Flutter Offline Audio: Cache and Play MP3s. Keep the chime short — under two seconds — and respect the device's silent mode where the platform requires it; a focus app that blasts sound during a meeting is a focus app that gets uninstalled.

Design for calm

The timer screen is the screen your user stares at for 25 minutes, so it has to earn that attention. ZenPeace's minimalist zen design follows a few rules that are worth stealing:

  • One primary action. Start/pause is the only prominent control. Settings, history, and the planner live one tap away, not on the timer screen.
  • A ring, not a wall of digits. A slowly draining progress ring communicates time remaining at a glance; the exact minutes and seconds are secondary text beneath it. Draw it with a CustomPaint painter so it renders at 60fps without rebuilding widgets.
  • No clutter during a session. Hide the task list, the stats teaser, and any promotional surface while a focus session is running. The timer screen should contain the ring, the phase label, and nothing else.
  • Dark, warm, quiet colors. High-contrast red countdowns raise the heart rate. Muted tones keep the session calm — the design should lower your pulse, not raise it. The animated ring deserves a note: driving it from the same Ticker that powers the engine means the animation and the countdown can never disagree. If you are new to frame-driven UI, Flutter Breathing Pacer Animation Guide covers the same CustomPainter and Ticker techniques in a different context.

Ship it fully offline

ZenPeace works fully offline, and that is a feature, not a limitation. No accounts, no sync, no server means the timer works in airplane mode, in a basement office, and on the first install before any onboarding completes. It also means every architectural decision above had to work without a backend: local notifications instead of push, local storage instead of a cloud database, bundled audio instead of streaming. The pattern generalizes. If you are building any time-sensitive mobile feature — alarms, medication reminders, habit streaks — assume the OS will kill your process, assume the network is unavailable, and design around the OS scheduler and local storage from day one. The users who notice are the ones who keep the app. Get in touch and let's talk about your project.

Frequently asked questions

Why does my Flutter timer drift or freeze in the background?
Timer.periodic schedules callbacks on the event loop, so each tick can be delayed and the OS can stop ticks entirely when the app is throttled. Set a DateTime end point when the session starts and compute the remaining time from the wall clock on every frame instead.
How do I fire a timer alert when the app is closed?
Schedule a local notification with the OS-level scheduler at the moment the session starts, using AndroidScheduleMode.exactAllowWhileIdle so Doze mode does not delay it. Cancel it if the user ends the session early, and rebuild state honestly on cold start.
How long should a pomodoro session be in a Flutter app?
Start with the classic 25-minute focus and 5-minute break defaults, with a long break after every fourth focus session. Let users customize the lengths and persist their choices locally, since the right durations vary by person and task.
How should I store completed pomodoro sessions for analytics?
Append one small raw event per finished session keyed by day, using local storage. Aggregate daily, weekly, and monthly charts from those events on read, which keeps the stored history immune to bugs in your chart math.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
Advertisement
All articles
  • Mobile Development

    6 min read

    Flutter ZIP-Code Autofill That Just Works

    Add ZIP-code autofill to your Flutter app: string-safe validation, debounced lookups, stale-response cancellation, and defensive parsing from a production app.

  • Mobile Development

    10 min read

    Flutter Subscriptions With RevenueCat

    Ship reliable Flutter in-app subscriptions with RevenueCat: offerings, paywalls, entitlements, and sandbox testing, distilled from the Butter Skin app.

  • Mobile Development

    8 min read

    Flutter Wake-Up Challenge Alarm

    Build a snooze-proof wake-up alarm in Flutter with interactive math and shake dismissal challenges, reliable scheduling, and lessons from the Rise Baby app.