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.

Getting Started

Board and Library Setup

For this project, we’re using a LilyGo T-Display clone, 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:

Display the splash screen on boot.

Start a focus session countdown.

Automatically switch to a break session when the timer reaches zero.

After four cycles, initiate a longer break.

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.