# Minimalist Pomodoro Timer w/ ESP32 T-Display

> Buildin a Pomodoro timer with dynamic visuals and easy interaction using an ESP32 T-Display.

By Zsolt Bizderi · Published 2025-03-07
Canonical: https://ambientnode.uk/minimalist-pomodoro-timer-w-esp32-t-display

The ESP32 T-Display is a versatile and tiny board that includes a built-in TFT screen, great for small but impactful projects. In this post, I'll walk you through how to turn it into a simple Pomodoro timer that not only keeps track of your focus and break sessions but also looks great at the same time.



![](/media/96eac630-754d-4e99-9a55-b7a4b47d2f14/1280.webp)



---

## Getting Started

### Board and Library Setup

For this project, we’re using a LilyGo T-Display [clone](https://www.aliexpress.com/item/1005005970553639.html), which requires adjusting the default library a bit, but other than that it is identical to the original LilyGo T-Display.

* **Install the TFT\_eSPI Library:** Open the Arduino IDE and add the `TFT_eSPI` library via the Library Manager.
* **Edit Configuration Files:**
  + Navigate to the Arduino library folder in your file manager.
  + Open the `TFT_eSPI` folder and edit `User_Setup.h`.
    - Define the driver for `ST7798_DRIVER`.
    - Set the display dimensions:  
      `#define TFT_WIDTH 135  
      #define TFT_HEIGHT 240`
    - Save the file.
  + In the same folder, edit `User_Setup_Select.h` and include:
    - `#include <User_Setups/Setup25_TTGO_T_Display.h>`
* **Install ESP32 Board Support:** If you haven’t already, install the ESP32 boards under the Arduino IDE’s Board Manager.
* **Connect the Board:** Keep Arduino IDE open and connect the ESP32 T-Display board. Ensure the correct port is selected, and the board should be recognized as a LilyGo T-Display.
* **Test the Display:** The `TFT_eSPI` library includes several examples to test your setup. Run one to confirm the display is working correctly.

---

## Designing the Pomodoro Timer

### Display Layout

The built-in TFT screen is just 1.14 inches, so efficient use of the space is a must. Here's the layout plan:

* **Splash Screen:** To add a polished touch, the clock starts with a short splash screen when powered up.
* **Timer:** A countdown timer is displayed in the center of the screen, showing the remaining time for the current session.
* **Session Label:** Above the timer, the session name alternates between "Focus" and "Break" as per the Pomodoro technique:
  + Three focus sessions of 25 minutes each.
  + A 5-minute break after each focus session.
  + A longer break after four cycles.
* **Progress Bar:** A progress bar at the bottom indicates the session's progress at a glance. For variety, the progress bar changes colour on every boot.

### Button Functionality

The board features two buttons:

* Both buttons reset the current session's timer.
* Resetting the board with the physical reset button reboots it and starts with "Focus" mode again.

---

## Coding the Timer

The Pomodoro timer logic alternates between focus and break sessions. Here’s the high-level flow:

1. Display the splash screen on boot.
2. Start a focus session countdown.
3. Automatically switch to a break session when the timer reaches zero.
4. After four cycles, initiate a longer break.
5. Reset functionality for manual control.

```
#include <TFT_eSPI.h> // Include the TFT library
#include <SPI.h>
#include <stdlib.h> // For random number generation

// TFT Display object
TFT_eSPI tft = TFT_eSPI();

// Pomodoro settings
const int workDuration = 25 * 60;       // 25 minutes in seconds
const int shortBreakDuration = 5 * 60; // 5 minutes in seconds
const int longBreakDuration = 15 * 60; // 15 minutes in seconds
int timer = workDuration;              // Current timer value
bool isWorkSession = true;             // Track work/break session
int completedWorkSessions = 0;         // Track completed work sessions

// Timer update tracking
unsigned long lastUpdate = 0;

// Progress bar color
uint16_t progressBarColor;

// Button pins
#define BUTTON1_PIN 0
#define BUTTON2_PIN 35

void setup() {
  // Initialize display
  tft.init();
  tft.setRotation(1);
  tft.fillScreen(TFT_BLACK);

  // Display splash screen
  displaySplashScreen();

  // Initialize progress bar color
  randomSeed(analogRead(A0));
  progressBarColor = tft.color565(random(0, 256), random(0, 256), random(0, 256));

  // Display initial timer and progress bar
  updateTimerDisplay(true);
  drawProgressBar(true);

  // Initialize buttons
  pinMode(BUTTON1_PIN, INPUT_PULLUP);
  pinMode(BUTTON2_PIN, INPUT_PULLUP);
}

void loop() {
  handleButtonPress();

  // Update the timer every second
  if (millis() - lastUpdate >= 1000) {
    lastUpdate = millis();
    timer--;

    // When timer reaches zero, switch sessions
    if (timer <= 0) {
      switchSession();
      updateTimerDisplay(true);
      drawProgressBar(true);
    } else {
      updateTimerDisplay(false);
      drawProgressBar(false);
    }
  }
}

void switchSession() {
  if (isWorkSession) {
    // End of a work session
    completedWorkSessions++;
    isWorkSession = false;

    // Long break after 4 work sessions, otherwise short break
    timer = (completedWorkSessions % 4 == 0) ? longBreakDuration : shortBreakDuration;
  } else {
    // End of a break session
    isWorkSession = true;
    timer = workDuration;
  }

  // Randomize progress bar color
  progressBarColor = tft.color565(random(0, 256), random(0, 256), random(0, 256));
}

void handleButtonPress() {
  if (digitalRead(BUTTON1_PIN) == LOW || digitalRead(BUTTON2_PIN) == LOW) {
    // Reset the timer to the current session's duration
    timer = isWorkSession ? workDuration : ((completedWorkSessions % 4 == 0) ? longBreakDuration : shortBreakDuration);

    // Update display
    updateTimerDisplay(true);
    drawProgressBar(true);

    // Debounce delay
    delay(200);
  }
}

void displaySplashScreen() {
  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("ambientnode.co.uk", tft.width() / 2, tft.height() / 2, 4);

  delay(2000);

  for (int fade = 255; fade >= 0; fade -= 5) {
    tft.setTextColor(tft.color565(fade, fade, fade), TFT_BLACK);
    tft.drawString("ambientnode.co.uk", tft.width() / 2, tft.height() / 2, 4);
    delay(30);
  }
  tft.fillScreen(TFT_BLACK);
}

void updateTimerDisplay(bool fullUpdate) {
  static bool lastWorkSession = !isWorkSession;
  static int lastMinutes = -1, lastSeconds = -1;

  // Update session label
  if (fullUpdate || lastWorkSession != isWorkSession) {
    tft.fillRect(0, 0, tft.width(), 50, TFT_BLACK);
    tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
    tft.setTextDatum(TC_DATUM);
    tft.setTextFont(2);
    tft.drawString(isWorkSession ? "focus" : "break", tft.width() / 2, 20, 4);
    lastWorkSession = isWorkSession;
  }

  // Update time display
  int minutes = timer / 60;
  int seconds = timer % 60;

  if (fullUpdate || minutes != lastMinutes || seconds != lastSeconds) {
    char timeBuffer[6];
    sprintf(timeBuffer, "%02d:%02d", minutes, seconds);

    tft.setTextColor(TFT_BLACK, TFT_BLACK);
    tft.setTextDatum(MC_DATUM);
    tft.drawString(String(lastMinutes) + ":" + String(lastSeconds), tft.width() / 2, tft.height() / 2 + 10, 7);

    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.drawString(timeBuffer, tft.width() / 2, tft.height() / 2 + 10, 7);

    lastMinutes = minutes;
    lastSeconds = seconds;
  }
}

void drawProgressBar(bool fullUpdate) {
  static int lastFilledWidth = -1;

  float progress = 1.0 - (float)timer / (isWorkSession ? workDuration : ((completedWorkSessions % 4 == 0) ? longBreakDuration : shortBreakDuration));
  int progressBarWidth = tft.width() - 20;
  int filledWidth = progress * progressBarWidth;

  if (fullUpdate || filledWidth != lastFilledWidth) {
    int barY = tft.height() - 25;

    tft.fillRect(10, barY, progressBarWidth, 8, TFT_BLACK);

    for (int i = 0; i < filledWidth; i++) {
      float t = (float)i / filledWidth;
      uint16_t color = blendColor(progressBarColor, TFT_BLACK, t);
      tft.drawFastVLine(10 + i, barY, 8, color);
    }

    lastFilledWidth = filledWidth;
  }
}

uint16_t blendColor(uint16_t color1, uint16_t color2, float t) {
  if (t < 0.0) t = 0.0;
  if (t > 1.0) t = 1.0;

  uint8_t r1 = (color1 >> 11) & 0x1F;
  uint8_t g1 = (color1 >> 5) & 0x3F;
  uint8_t b1 = color1 & 0x1F;

  uint8_t r2 = (color2 >> 11) & 0x1F;
  uint8_t g2 = (color2 >> 5) & 0x3F;
  uint8_t b2 = color2 & 0x1F;

  r1 <<= 3; g1 <<= 2; b1 <<= 3;
  r2 <<= 3; g2 <<= 2; b2 <<= 3;

  uint8_t r = r1 + t * (r2 - r1);
  uint8_t g = g1 + t * (g2 - g1);
  uint8_t b = b1 + t * (b2 - b1);

  return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
}
```

---

## Housing the Timer

To actually start using the timer and finish this project, I printed a case. You can [find the STLs here](https://www.thingiverse.com/thing:4183337).
