Community project

Touchscreen Pomodoro Timer

Simon Hatfield

Published July 18, 2026

ESP321 component5 assembly steps
Remix this project
Photo of Touchscreen Pomodoro Timer

Build a compact Pomodoro timer with a vibrant 1.28-inch round touchscreen display powered by an ESP32-S3 microcontroller. This project combines the Waveshare ESP32-S3-Touch-LCD module with capacitive touch controls to create an interactive focus timer that guides users through work and break cycles with visual feedback and celebratory confetti animations.

This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions to get the timer running in minutes. Flash the included firmware to enable intuitive touch gestures—tap to pause/resume, swipe to adjust session length, and long-press to reset. An optional LiPo battery connection makes the timer portable for use anywhere.

Wiring diagram

Interactive · read-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
Waveshare ESP32-S3-Touch-LCD-1.281All-in-one ESP32-S3 board with 1.28" round 240x240 GC9A01 LCD, CST816S capacitive touch, QMI8658 IMU, LiPo charger. Touch screen is the primary input — tap to pause/resume, swipe up/down to adjust session durations.

Assembly

5 steps
  1. What's in the box

    The Waveshare ESP32-S3-Touch-LCD-1.28 is a self-contained development board. It already has the 1.28" round 240×240 GC9A01 display, the CST816S capacitive touch layer, and the QMI8658 IMU soldered on. No external wiring to the display or touch controller is needed.

    • Tip: Inspect the USB-C port and make sure no debris is inside.
    • Tip: The ceramic antenna (the white block near one corner) must be kept clear of metal objects for Wi-Fi/BLE to work — not used for this project but good practice.
  2. Connect USB-C cable

    Plug a USB-C cable from the board's USB-C port to your computer or a 5V USB charger. The board is powered and ready to flash via Schematik's Deploy button.

    • Tip: Use a data-capable USB-C cable, not a charge-only cable, so Schematik can communicate with the board.
    • Tip: The onboard CH343P chip handles USB-to-serial conversion automatically — no extra adapter needed.
    • Do NOT connect anything else while the board is live unless you know what you are doing. The SH1.0 GPIO connector on the side exposes 3.3V GPIO pins.
  3. Flash the firmware

    Click the Deploy button in Schematik. The board will auto-reset into download mode via the CH343P RTS/DTR handshake. If the flash fails, hold BOOT then tap RESET, then release BOOT — this manually enters download mode. After flashing, the board restarts automatically.

    • Tip: The first deploy may take 30-60 seconds as PlatformIO downloads the ESP32-S3 toolchain and libraries.
    • Tip: Monitor the Deploy panel for 'Done in X seconds' to confirm success.
  4. First run & touch controls

    After flashing, the round display lights up with a red arc and a 25:00 countdown — that is your focus session. Touch controls: • Tap anywhere → pause / resume the timer • Swipe LEFT → decrease current session length by 1 minute (min 1 min) • Swipe RIGHT → increase current session length by 1 minute (max 60 min) • Hold for > 0.6 s → reset and restart the session • When session ends (DONE! screen appears) → tap to move to the next phase At the end of a focus session confetti bursts on screen for ~3 seconds before the DONE! prompt.

    • Tip: The capacitive touch is most accurate in the central area of the round display; the very edge can be slightly less responsive — this is normal.
    • Tip: Adjusting the session length mid-countdown restarts the countdown with the new duration.
    • Tip: Focus sessions show a red arc; break sessions show a green arc; a paused timer shows a yellow arc.
  5. Optional: LiPo battery

    The board has an MX1.25 2-pin battery header (near the bottom edge) for a 3.7V LiPo battery. Connect a single-cell 3.7V LiPo with an MX1.25 connector to make the timer portable. The onboard ETA6096 charger handles charging automatically when USB-C is also connected.

    • Tip: A 500–1000 mAh cell will run the timer for many hours.
    • Only use a single-cell 3.7V LiPo. Never exceed the voltage rating.
    • Ensure the LiPo's polarity matches the header — positive to the + pad. Check the silkscreen on the PCB before connecting.

Pin assignments

Board wiring reference
PinConnectionType
EXTboard USB-CUSB-C power / PCpower

Firmware

ESP32
firmware.cppDeploy to device
// ============================================================
// Pomodoro Focus Timer — Waveshare ESP32-S3-Touch-LCD-1.28
// 240×240 round GC9A01 display · CST816S capacitive touch
//
// Controls (touch screen):
//   Single tap      → pause / resume timer
//   Swipe LEFT      → decrease current session by 1 min (min 1)
//   Swipe RIGHT     → increase current session by 1 min (max 60)
//   Long-press (>600ms) → reset / restart session
//   When timer just finished → tap to start next session
//
// States: SETTINGS → FOCUS → BREAK → (loop)
// Confetti fires when focus session completes.
// ============================================================

#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <TFT_eSPI.h>

// ---- Pin constants ----------------------------------------
#define TFT_BL_PIN        2
#define TOUCH_SDA_PIN     6
#define TOUCH_SCL_PIN     7
#define TOUCH_RST_PIN    13
#define TOUCH_INT_PIN     5

// ---- CST816S registers (I2C addr 0x15) --------------------
#define CST816S_ADDR     0x15
#define REG_GESTURE      0x01
#define REG_FINGER_NUM   0x02
#define REG_XPOS_H       0x03
#define REG_XPOS_L       0x04
#define REG_YPOS_H       0x05
#define REG_YPOS_L       0x06
// Gesture codes
#define GEST_NONE        0x00
#define GEST_SWIPE_UP    0x01
#define GEST_SWIPE_DOWN  0x02
#define GEST_SWIPE_LEFT  0x03
#define GEST_SWIPE_RIGHT 0x04
#define GEST_SINGLE_TAP  0x05
#define GEST_LONG_PRESS  0x0C

// ---- Display ----------------------------------------------

// Hoisted type definitions
enum Phase { FOCUS, BREAK };

enum RunState { RUNNING, PAUSED, DONE };

struct Particle {
  float x, y, vx, vy;
  uint16_t col;
  uint8_t  size;
  bool     alive;
};

struct TouchData { uint8_t gesture; bool touched; int x; int y; };


// Forward declarations
TouchData readTouch();
void initTouch();
void swapPhase();

TFT_eSPI tft = TFT_eSPI();
TFT_eSprite spr = TFT_eSprite(&tft);

// ---- Colours (RGB565) -------------------------------------
#define COL_BG           0x0841   // very dark navy
#define COL_RING_BG      0x2124   // dim grey ring
#define COL_FOCUS        0xF800   // red-orange
#define COL_BREAK        0x07E0   // green
#define COL_PAUSED       0xFFE0   // yellow
#define COL_TEXT         0xFFFF   // white
#define COL_SUB          0xC618   // light grey

// ---- Timer state ------------------------------------------



Phase      phase       = FOCUS;
RunState   runState    = RUNNING;

int focusMinutes  = 25;
int breakMinutes  =  5;

uint32_t sessionSecs = 0;   // total seconds in current session
uint32_t remainSecs  = 0;   // seconds remaining
uint32_t lastTick    = 0;   // millis of last second decrement

// ---- Touch tracking ---------------------------------------
bool     touchWasDown   = false;
uint32_t touchDownAt    = 0;
bool     gestureHandled = false;

// ---- Confetti ---------------------------------------------
#define MAX_CONFETTI 60

Particle particles[MAX_CONFETTI];
bool confettiActive = false;
uint32_t confettiStart = 0;
#define CONFETTI_DURATION_MS 3000

// ---- Forward declarations ---------------------------------
void resetSession();
void drawFace();
void drawArc(float progress, uint16_t colour);
void drawConfettiFrame();
void spawnConfetti();
uint16_t randomCol();

// ---- CST816S helpers --------------------------------------


TouchData readTouch() {
    TouchData td = {GEST_NONE, false, 0, 0};
    Wire.beginTransmission(CST816S_ADDR);
    Wire.write(REG_GESTURE);
    if (Wire.endTransmission(false) != 0) return td;
    Wire.requestFrom((uint8_t)CST816S_ADDR, (uint8_t)6);
    if (Wire.available() < 6) return td;
    uint8_t gest     = Wire.read();
    uint8_t fingers  = Wire.read() & 0x0F;
    uint8_t xh       = Wire.read() & 0x0F;
    uint8_t xl       = Wire.read();
    uint8_t yh       = Wire.read() & 0x0F;
    uint8_t yl       = Wire.read();
    td.gesture = gest;
    td.touched = (fingers > 0);
    td.x = (xh << 8) | xl;
    td.y = (yh << 8) | yl;
    return td;
}

void initTouch() {
    pinMode(TOUCH_RST_PIN, OUTPUT);
    digitalWrite(TOUCH_RST_PIN, LOW);  delay(20);
    digitalWrite(TOUCH_RST_PIN, HIGH); delay(50);
    Wire.begin(TOUCH_SDA_PIN, TOUCH_SCL_PIN, 400000UL);
}

// ---- Helpers ----------------------------------------------
void resetSession() {
    sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
    remainSecs  = sessionSecs;
    runState    = RUNNING;
    lastTick    = millis();
    confettiActive = false;
}

void swapPhase() {
    phase = (phase == FOCUS) ? BREAK : FOCUS;
    resetSession();
}

// ---- Arc drawing ------------------------------------------
#define ARC_R    105   // outer radius of arc
#define ARC_W    10    // arc width in pixels
#define CX       120
#define CY       120

void drawArc(float progress, uint16_t colour) {
    // Draw background arc (dim)
    for (int deg = 0; deg < 360; deg++) {
        float rad = deg * 0.01745329f;
        int x0 = CX + (int)((ARC_R - ARC_W) * sinf(rad));
        int y0 = CY - (int)((ARC_R - ARC_W) * cosf(rad));
        int x1 = CX + (int)(ARC_R * sinf(rad));
        int y1 = CY - (int)(ARC_R * cosf(rad));
        spr.drawLine(x0, y0, x1, y1, COL_RING_BG);
    }
    // Draw foreground arc (progress)
    int endDeg = (int)(progress * 360.0f);
    for (int deg = 0; deg < endDeg; deg++) {
        float rad = deg * 0.01745329f;
        int x0 = CX + (int)((ARC_R - ARC_W) * sinf(rad));
        int y0 = CY - (int)((ARC_R - ARC_W) * cosf(rad));
        int x1 = CX + (int)(ARC_R * sinf(rad));
        int y1 = CY - (int)(ARC_R * cosf(rad));
        spr.drawLine(x0, y0, x1, y1, colour);
    }
}

// ---- Confetti helpers -------------------------------------
uint16_t randomCol() {
    static const uint16_t cols[] = {0xF800, 0x07E0, 0x001F, 0xFFE0, 0xF81F, 0x07FF, 0xFD20};
    return cols[random(0, 7)];
}

void spawnConfetti() {
    for (int i = 0; i < MAX_CONFETTI; i++) {
        particles[i].x    = random(30, 210);
        particles[i].y    = random(30, 100);
        particles[i].vx   = ((float)random(-30, 30)) / 10.0f;
        particles[i].vy   = ((float)random(5, 20))   / 10.0f;
        particles[i].col  = randomCol();
        particles[i].size = random(2, 5);
        particles[i].alive = true;
    }
    confettiActive = true;
    confettiStart  = millis();
}

void drawConfettiFrame() {
    for (int i = 0; i < MAX_CONFETTI; i++) {
        if (!particles[i].alive) continue;
        particles[i].x += particles[i].vx;
        particles[i].y += particles[i].vy;
        particles[i].vy += 0.15f;  // gravity
        if (particles[i].y > 240 || particles[i].x < 0 || particles[i].x > 240)
            particles[i].alive = false;
        else
            spr.fillRect((int)particles[i].x, (int)particles[i].y,
                         particles[i].size, particles[i].size, particles[i].col);
    }
}

// ---- Main face drawing ------------------------------------
void drawFace() {
    spr.fillSprite(COL_BG);

    uint16_t arcCol = (runState == PAUSED) ? COL_PAUSED :
                      (phase == FOCUS)      ? COL_FOCUS  : COL_BREAK;

    float progress = (sessionSecs > 0) ?
        1.0f - ((float)remainSecs / (float)sessionSecs) : 1.0f;

    if (runState != DONE) {
        drawArc(progress, arcCol);
    }

    if (confettiActive) {
        drawConfettiFrame();
    }

    // ---- Centre text ----
    spr.setTextDatum(MC_DATUM);

    if (runState == DONE) {
        // "DONE!" splash with confetti still going
        spr.setTextColor(COL_FOCUS, COL_BG);
        spr.setTextSize(1);
        spr.loadFont(""); // default
        spr.drawString(phase == BREAK ? "BREAK!" : "DONE!", CX, CY - 16, 4);
        spr.setTextColor(COL_SUB, COL_BG);
        spr.drawString("tap to continue", CX, CY + 20, 2);
        if (confettiActive) drawConfettiFrame();
    } else {
        // Countdown mm:ss
        uint32_t mins = remainSecs / 60;
        uint32_t secs = remainSecs % 60;
        char timeBuf[8];
        snprintf(timeBuf, sizeof(timeBuf), "%02u:%02u", mins, secs);
        spr.setTextColor(COL_TEXT, COL_BG);
        spr.drawString(timeBuf, CX, CY - 10, 6);

        // Phase label
        const char* label = (phase == FOCUS) ? "FOCUS" : "BREAK";
        uint16_t labelCol = (phase == FOCUS) ? COL_FOCUS : COL_BREAK;
        spr.setTextColor(labelCol, COL_BG);
        spr.drawString(label, CX, CY + 34, 2);

        // Paused indicator
        if (runState == PAUSED) {
            spr.setTextColor(COL_PAUSED, COL_BG);
            spr.drawString("paused", CX, CY + 54, 2);
        }

        // Adjustment hint (bottom)
        spr.setTextColor(COL_SUB, COL_BG);
        spr.drawString("< swipe > to adjust", CX, 200, 1);
        spr.drawString("tap: pause/resume", CX, 212, 1);
        spr.drawString("hold: reset", CX, 224, 1);
    }

    spr.pushSprite(0, 0);
}

// ---- setup() ----------------------------------------------
void setup() {
    Serial.begin(115200);

    // Backlight on
    pinMode(TFT_BL_PIN, OUTPUT);
    digitalWrite(TFT_BL_PIN, HIGH);

    // Display init
    tft.init();
    tft.setRotation(0);
    tft.fillScreen(TFT_BLACK);

    // Sprite (full-screen double buffer)
    spr.createSprite(240, 240);
    spr.setSwapBytes(true);

    // Touch
    initTouch();

    // Seed RNG
    randomSeed(esp_random());

    // Start first session
    resetSession();

    Serial.println("Pomodoro timer ready.");
}

// ---- loop() -----------------------------------------------
void loop() {
    uint32_t now = millis();

    // ---- Tick timer ----
    if (runState == RUNNING && (now - lastTick >= 1000)) {
        lastTick += 1000;
        if (remainSecs > 0) {
            remainSecs--;
        }
        if (remainSecs == 0 && runState == RUNNING) {
            runState = DONE;
            if (phase == FOCUS) spawnConfetti();
        }
    }

    // ---- Advance confetti timeout ----
    if (confettiActive && (now - confettiStart > CONFETTI_DURATION_MS)) {
        confettiActive = false;
    }

    // ---- Touch handling ----
    TouchData td = readTouch();

    if (td.touched && !touchWasDown) {
        // touch just pressed
        touchWasDown   = true;
        touchDownAt    = now;
        gestureHandled = false;
    }

    if (td.touched && touchWasDown && !gestureHandled) {
        uint32_t held = now - touchDownAt;

        // Long-press → reset
        if (held > 600) {
            gestureHandled = true;
            resetSession();
        }
        // Swipe detected
        else if (td.gesture == GEST_SWIPE_LEFT && !gestureHandled) {
            gestureHandled = true;
            if (runState == DONE) {
                // ignore swipe on done screen, treat as tap
            } else {
                if (phase == FOCUS) focusMinutes  = max(1, focusMinutes  - 1);
                else                breakMinutes  = max(1, breakMinutes  - 1);
                // Adjust remaining proportionally
                sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
                remainSecs  = sessionSecs; // restart with new duration
            }
        }
        else if (td.gesture == GEST_SWIPE_RIGHT && !gestureHandled) {
            gestureHandled = true;
            if (runState != DONE) {
                if (phase == FOCUS) focusMinutes  = min(60, focusMinutes  + 1);
                else                breakMinutes  = min(60, breakMinutes  + 1);
                sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
                remainSecs  = sessionSecs;
            }
        }
    }

    if (!td.touched && touchWasDown) {
        // finger lifted
        uint32_t held = now - touchDownAt;
        if (!gestureHandled && held < 600) {
            // Short tap
            if (runState == DONE) {
                // Move to next phase
                swapPhase();
            } else if (runState == RUNNING) {
                runState = PAUSED;
            } else if (runState == PAUSED) {
                runState  = RUNNING;
                lastTick  = now;   // avoid large tick jump
            }
        }
        touchWasDown   = false;
        gestureHandled = false;
    }

    // ---- Draw ----
    drawFace();

    // Small delay to keep ~30 fps without hogging the CPU
    delay(33);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik