Ellie Cai
Published © GPL3+

My Tiny Pomodoro Goblin

I built a tiny Pomodoro device that kept asking me to rest, without knowing how to code.

BeginnerFull instructions provided2 hours144
My Tiny Pomodoro Goblin

Things used in this project

Hardware components

Seeed Studio XIAO ESP32S3
Seeed Studio XIAO ESP32S3
×1
Seeed Studio Round Display for XIAO
Seeed Studio Round Display for XIAO
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)

Story

Read more

Code

Tiny Rest Reminder based on Seeed Studio XIAO EPS32S3

Arduino
/*
  XIAO ESP32S3 + Seeed Studio Round Display
  Focus / Break Reminder

  Features:
  - Reminds user to rest after 1 hour
  - Break duration: 2 minutes
  - Buttons:
      Skip
      Remind in 5 min
      Start Break
  - Simple circular UI
  - Wellness icons:
      Water
      Tree
      Eye

  Libraries required:
  - TFT_eSPI
  - Seeed_Arduino_RoundDisplay

  Recommended:
  Install "Seeed Arduino Round Display" from Library Manager

  Board:
  Seeed XIAO ESP32S3

  Display:
  240x240 round LCD
*/

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

TFT_eSPI tft = TFT_eSPI();

#define SCREEN_W 240
#define SCREEN_H 240
#define CENTER_X 120
#define CENTER_Y 120

// ---------- Timing ----------
const unsigned long WORK_INTERVAL = 60UL * 60UL * 1000UL;   // 1 hour
const unsigned long BREAK_INTERVAL = 2UL * 60UL * 1000UL;   // 2 minutes
const unsigned long SNOOZE_INTERVAL = 5UL * 60UL * 1000UL;  // 5 minutes

unsigned long sessionStart = 0;
unsigned long breakStart = 0;
unsigned long snoozeStart = 0;

// ---------- States ----------
enum AppState {
  WORKING,
  REMINDER,
  BREAKING,
  SNOOZE
};

AppState currentState = WORKING;
AppState lastState = WORKING;

// ---------- Touch Areas ----------
struct ButtonArea {
  int x;
  int y;
  int w;
  int h;
};

ButtonArea btnStart  = {60, 155, 120, 28};
ButtonArea btnSkip   = {40, 195, 70, 25};
ButtonArea btnSnooze = {130, 195, 70, 25};

// ---------- Helpers ----------
bool inButton(int tx, int ty, ButtonArea b) {
  return (tx > b.x &&
          tx < b.x + b.w &&
          ty > b.y &&
          ty < b.y + b.h);
}

// ---------- Drawing ----------
void drawRoundBackground() {
  tft.fillScreen(TFT_BLACK);

  // soft center circle
  tft.fillCircle(CENTER_X, CENTER_Y, 110, TFT_DARKGREEN);
  tft.fillCircle(CENTER_X, CENTER_Y, 100, TFT_BLACK);
}

void drawTitle(String text) {
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);

  // 长标题自动缩小
  if (text.length() > 14) {
    tft.setTextSize(1);
  } else {
    tft.setTextSize(2);
  }

  tft.drawString(text, CENTER_X, 40);
}

void drawSubtitle(String text) {
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
  tft.setTextSize(1);

  tft.drawString(text, CENTER_X, 65);
}

// ---------- Wellness Icons ----------
void drawWaterIcon(int x, int y) {
  tft.drawRoundRect(x - 12, y - 16, 24, 32, 4, TFT_CYAN);
  tft.fillRect(x - 10, y, 20, 12, TFT_CYAN);
}

void drawTreeIcon(int x, int y) {
  tft.fillCircle(x, y - 10, 12, TFT_GREEN);
  tft.fillRect(x - 3, y, 6, 18, TFT_BROWN);
}

void drawEyeIcon(int x, int y) {
  tft.drawEllipse(x, y, 20, 10, TFT_WHITE);
  tft.fillCircle(x, y, 4, TFT_BLUE);
}

void drawWellnessIcons() {
  drawWaterIcon(60, 110);
  drawTreeIcon(120, 120);
  drawEyeIcon(180, 110);

  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextSize(1);

  tft.setTextDatum(MC_DATUM);

  tft.drawString("Drink", 60, 150);
  tft.drawString("Nature", 120, 150);
  tft.drawString("Eyes", 180, 150);
}

// ---------- Buttons ----------
void drawButton(ButtonArea b, String label, uint16_t color) {
  tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, color);

  tft.setTextColor(TFT_BLACK);
  tft.setTextDatum(MC_DATUM);
  tft.setTextSize(1);

  tft.drawString(label, b.x + b.w / 2, b.y + b.h / 2);
}

void drawWorkingScreen() {
  drawRoundBackground();

  drawTitle("Focus Mode");

  unsigned long elapsed = millis() - sessionStart;
  int progress = map(elapsed, 0, WORK_INTERVAL, 0, 360);

  // progress ring
  tft.drawCircle(CENTER_X, CENTER_Y, 70, TFT_DARKGREY);

  for (int i = 0; i < progress; i += 4) {
    float rad = (i - 90) * DEG_TO_RAD;

    int x = CENTER_X + cos(rad) * 70;
    int y = CENTER_Y + sin(rad) * 70;

    tft.fillCircle(x, y, 3, TFT_GREEN);
  }

  updateWorkingTime();

  tft.setTextSize(1);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);

  tft.drawString("until break", CENTER_X, CENTER_Y + 30);
}

void drawReminderScreen() {
  drawRoundBackground();

  drawTitle("Time for a break");
  drawSubtitle("2 min reset");

  drawWellnessIcons();

  drawButton(btnStart, "Start Break", TFT_GREEN);
  drawButton(btnSkip, "Skip", TFT_ORANGE);
  drawButton(btnSnooze, "Snooze", TFT_CYAN);
}

void updateWorkingTime() {

  unsigned long elapsed = millis() - sessionStart;
  unsigned long remain = (WORK_INTERVAL - elapsed) / 1000UL;

  int mins = remain / 60;
  int secs = remain % 60;

  char buf[16];
  sprintf(buf, "%02d:%02d", mins, secs);

  // 只清中间数字区域
  tft.fillRect(90, 110, 60, 30, TFT_BLACK);

  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextSize(3);
  tft.setTextDatum(MC_DATUM);

  tft.drawString(buf, CENTER_X, CENTER_Y);
}

void updateBreakTime() {

  unsigned long elapsed = millis() - breakStart;
  unsigned long remain = (BREAK_INTERVAL - elapsed) / 1000UL;

  int mins = remain / 60;
  int secs = remain % 60;

  char buf[16];
  sprintf(buf, "%01d:%02d", mins, secs);

  // ✅ 只清“数字本体区域”,不要碰背景/UI
  tft.fillRect(CENTER_X - 40, CENTER_Y - 70, 80, 40, TFT_BLACK);

  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextSize(3);
  tft.setTextDatum(MC_DATUM);

    // 👉 放中间偏上,避开 tree
  tft.drawString(buf, CENTER_X, CENTER_Y - 50);
}

void drawBreakScreen() {

  drawRoundBackground();
  drawTitle("Relax");

  drawWellnessIcons();

  // 👉 关键:先画静态,再画动态
  updateBreakTime();
}

void drawSnoozeScreen() {
  drawRoundBackground();

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextSize(2);
  tft.drawString("Reminder Delayed", CENTER_X, 60);

  tft.setTextColor(TFT_CYAN);
  tft.setTextSize(2);
  tft.setTextDatum(MC_DATUM);
  
  tft.drawString("See you in 5 min", CENTER_X, CENTER_Y);
}

// ---------- Touch ----------
void handleTouch() {
  int32_t x, y;

  if (tft.getTouch(&x, &y)) {

    if (currentState == REMINDER) {

      if (inButton(x, y, btnStart)) {
        currentState = BREAKING;
        breakStart = millis();
      }

      else if (inButton(x, y, btnSkip)) {
        currentState = WORKING;
        sessionStart = millis();
      }

      else if (inButton(x, y, btnSnooze)) {
        currentState = SNOOZE;
        snoozeStart = millis();
      }
    }

    delay(250);
  }
}

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

  tft.init();
  tft.setRotation(0);

  sessionStart = millis();

  drawWorkingScreen();
}

// ---------- Loop ----------
void loop() {

  handleTouch();

  // 只有状态切换时才重绘整页
if (currentState != lastState) {

  switch (currentState) {

    case WORKING:
      drawWorkingScreen();
      break;

    case REMINDER:
      drawReminderScreen();
      break;

    case BREAKING:
      drawBreakScreen();
      break;

    case SNOOZE:
      drawSnoozeScreen();
      break;
  }

  lastState = currentState;
}
  // 工作状态计时
  if (currentState == WORKING) {

    static unsigned long lastUpdate = 0;

    if (millis() - lastUpdate >= 1000) {
      lastUpdate = millis();
      updateWorkingTime();
    }

    if (millis() - sessionStart >= WORK_INTERVAL) {
      currentState = REMINDER;
    }
  }

  // 休息状态计时
  if (currentState == BREAKING) {

    static unsigned long lastBreakUpdate = 0;

    if (millis() - lastBreakUpdate >= 1000) {
      lastBreakUpdate = millis();
      updateBreakTime();
    }

    if (millis() - breakStart >= BREAK_INTERVAL) {
      currentState = WORKING;
      sessionStart = millis();
    }
  }

  // snooze
  if (currentState == SNOOZE) {

    if (millis() - snoozeStart >= SNOOZE_INTERVAL) {
      currentState = REMINDER;
    }
  }

  delay(50);
}

Credits

Ellie Cai
1 project • 2 followers

Comments