Build a touchscreen todo list manager powered by an ESP32 and Cheap Yellow Display (CYD). This project displays tasks on a colorful LCD screen with touch controls for adding, completing, and deleting items. Tasks persist in the ESP32's flash storage, so your list survives power cycles.
This guide provides a complete wiring diagram, parts list, and ready-to-flash firmware. Assembly is straightforward—just connect the CYD to the ESP32. The firmware handles touch input, task rendering, undo functionality, and smooth animations without screen flicker.
Wiring diagram
Assemble it in 1 step
1. Connect the CYD
No extra parts or wires are needed. Place the original Micro-USB ILI9341 CYD on a non-metal surface and connect its Micro-USB socket to your PC with a USB data cable.
- A charge-only cable will power the screen but cannot edit the list.
Deploy the firmware
#include <Arduino.h>
#include <TFT_eSPI.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <SPI.h>
#include <XPT2046_Touchscreen.h>
// Global SPI uses VSPI; the display uses its separate HSPI instance.
XPT2046_Touchscreen touch(33,36);
TFT_eSPI tft;
Preferences prefs;
struct Task { String text; bool done; } tasks[20];
int count=0, page=0;
String line;
bool overflow=false, storage=false;
uint32_t lastPage=0;
TFT_eSprite canvas(&tft);
bool hasCanvas=false;
int focus=-1, deleting=-1, undoIndex=-1;
Task undoTask;
uint32_t undoUntil=0;
bool persist(){
JsonDocument d;JsonArray a=d.to<JsonArray>();
for(int n=0;n<count;n++){JsonObject o=a.add<JsonObject>();o["text"]=tasks[n].text;o["done"]=tasks[n].done;}
String saved;serializeJson(d,saved);
return storage && prefs.putString("list",saved)==saved.length();
}
// Render off-screen, then transfer one finished frame. Never blank the LCD
// between frames: clearing it before a transfer causes a visible flash.
// Visual-only refresh: preserve the proven panel and touch configuration.
const uint16_t BG=0x0842, CARD=0x1926, INK=0xFFFF, MUTED=0xADDB,
ACCENT=0x07D9, DONE=0xA106, DONE_EDGE=0xFD14;
uint32_t feedbackUntil=0, nextFeedback=0, animationStart=0;
// Always draw into the concrete sprite, never through a base-class reference.
template<class Surface> void paint(Surface &g) {
// Start every frame unclipped, including after a task or dialog disappears.
g.resetViewport();
g.setTextDatum(TL_DATUM);g.setTextSize(1);g.setTextPadding(0);
g.fillScreen(BG);
int complete=0;for(int i=0;i<count;i++)if(tasks[i].done)complete++;
// Compact brand mark and a deliberately bold typographic hierarchy.
g.fillRoundRect(12,11,5,32,2,ACCENT);
g.setFreeFont(&FreeSansBold18pt7b);
g.setTextColor(INK,BG);g.drawString("Today",26,3);
g.setFreeFont(NULL);
g.setTextColor(MUTED,BG);
String subtitle=String(count-complete)+" LEFT / YOUR DAILY FOCUS";
g.drawString(subtitle,27,43,1);
g.fillRoundRect(244,11,64,29,10,CARD);
g.setFreeFont(&FreeSansBold9pt7b);
String score=String(complete)+"/"+String(count);
g.setTextColor(ACCENT,CARD);g.drawString(score,276-g.textWidth(score)/2,17);
g.fillRoundRect(12,57,296,3,1,CARD);
if(count && complete)g.fillRoundRect(12,57,max(3,296*complete/count),3,1,ACCENT);
uint32_t elapsed=millis()-animationStart;
for(int r=0;r<4 && page*4+r<count;r++){
int local=constrain((int)elapsed-r*24,0,240);
int offset=0;
int slide=hasCanvas?18*(240-local)*(240-local)/(240*240):0;
// Cards fit inside their rows; keep the full-frame viewport throughout.
// Changing sprite viewports can leave later clears/transfers clipped.
int i=page*4+r,y=66+r*36+offset;
bool checked=tasks[i].done;
uint16_t surface=checked?DONE:CARD,edge=checked?DONE_EDGE:ACCENT;
g.fillRoundRect(12+slide,y+2,296-slide,32,8,0x0000);
g.fillRoundRect(12+slide,y,296-slide,32,8,surface);
g.fillRoundRect(12+slide,y+7,3,18,1,edge);
if(checked){
g.fillRoundRect(22,y+8,17,17,6,DONE_EDGE);
g.drawLine(26,y+16,30,y+20,INK);g.drawLine(30,y+20,36,y+12,INK);
g.drawLine(26,y+17,30,y+21,INK);g.drawLine(30,y+21,36,y+13,INK);
}else g.drawRoundRect(22,y+8,17,17,6,ACCENT);
g.setFreeFont(&FreeSansBold9pt7b);
String first=tasks[i].text,second="";
while(first.length() && g.textWidth(first)>244){
second=first.substring(first.length()-1)+second;first.remove(first.length()-1);
}
if(g.textWidth(second)>244){
while(second.length() && g.textWidth(second+"...")>244)second.remove(second.length()-1);
second+="...";
}
int ty=y+(second.length()?0:7);
g.setTextColor(INK,surface);g.drawString(first,47,ty);
if(second.length())g.drawString(second,47,y+16);
if(checked){
g.drawFastHLine(47,ty+9,g.textWidth(first),DONE_EDGE);
if(second.length())g.drawFastHLine(47,y+25,g.textWidth(second),DONE_EDGE);
}
}
if(!count){
g.setFreeFont(&FreeSansBold12pt7b);
g.setTextColor(INK,BG);g.drawString("A fresh start.",83,102);
g.setFreeFont(&FreeSans9pt7b);
g.setTextColor(MUTED,BG);g.drawString("Add tasks from your USB editor",32,138);
}
g.setFreeFont(NULL);
g.drawFastHLine(12,215,296,CARD);
g.setTextColor(MUTED,BG);g.drawString("HOLD: DELETE",12,226,1);
int pages=max(1,(count+3)/4);
int dotsWidth=(pages-1)*16+14;
for(int i=0;i<pages;i++)g.fillRoundRect(160-dotsWidth/2+i*16,227,i==page?14:5,4,2,i==page?ACCENT:CARD);
g.setTextColor(ACCENT,BG);
String pageLabel=String(page+1)+" / "+String(pages)+" >";
g.drawString(pageLabel,308-g.textWidth(pageLabel,1),226,1);
if(focus>=0 && focus<count){
g.fillScreen(BG);
g.setFreeFont(&FreeSans9pt7b);g.setTextColor(ACCENT,BG);
g.drawString("< BACK",16,12);g.drawString("FOCUS",237,12);
g.setFreeFont(&FreeSansBold12pt7b);g.setTextColor(INK,BG);
String rest=tasks[focus].text;
int y=62;
while(rest.length()){
int n=rest.length();while(n>1 && g.textWidth(rest.substring(0,n))>280)n--;
if(n<(int)rest.length()){int space=rest.lastIndexOf(' ',n);if(space>0)n=space;}
g.drawString(rest.substring(0,n),20,y);rest=rest.substring(n);rest.trim();y+=31;
}
g.fillRoundRect(16,181,288,42,12,tasks[focus].done?DONE:ACCENT);
g.setFreeFont(&FreeSans9pt7b);g.setTextColor(tasks[focus].done?INK:BG);
g.drawString(tasks[focus].done?"Completed - tap to reopen":"Mark complete",40,194);
}
if(undoIndex>=0 && deleting<0 && focus<0){
g.fillRoundRect(12,208,296,30,8,CARD);
g.setFreeFont(&FreeSans9pt7b);g.setTextColor(INK,CARD);g.drawString("Task deleted",22,215);
g.setTextColor(ACCENT,CARD);g.drawString("UNDO",243,215);
}
if(deleting>=0){
g.fillRoundRect(20,62,280,132,14,CARD);
g.drawRoundRect(20,62,280,132,14,DONE_EDGE);
g.setFreeFont(&FreeSansBold12pt7b);g.setTextColor(INK,CARD);g.drawString("Delete this task?",38,78);
g.setFreeFont(&FreeSans9pt7b);g.setTextColor(MUTED,CARD);g.drawString("Undo available for 5 seconds",36,112);
g.fillRoundRect(34,145,116,34,9,BG);g.fillRoundRect(170,145,116,34,9,DONE);
g.setTextColor(INK);g.drawString("Cancel",61,154);g.drawString("Delete",199,154);
}
int32_t remaining=(int32_t)(feedbackUntil-millis());
if(remaining>0)g.fillRoundRect(12,237,max(2,(int)(296*min(remaining,(int32_t)320)/320)),2,1,ACCENT);
}
void draw(){
static int previousPage=-1;
if(previousPage!=page){previousPage=page;animationStart=millis();feedbackUntil=millis()+340;}
if(hasCanvas){
canvas.resetViewport();
canvas.fillSprite(BG);
paint(canvas);
canvas.resetViewport();
tft.resetViewport();
canvas.pushSprite(0,0);
}
else paint(tft);
}
bool valid(JsonArray a) {
if(a.isNull() || a.size()>20) return false;
for(JsonVariant v:a) {
if(!v["text"].is<const char*>() || !v["done"].is<bool>()) return false;
String s=v["text"].as<String>();
if(s.length()<1 || s.length()>44) return false;
for(size_t i=0;i<s.length();i++) if(s[i]<32 || s[i]>126) return false;
}
return true;
}
void load(JsonArray a) {focus=-1;deleting=-1;undoIndex=-1;count=0; for(JsonVariant v:a) {tasks[count++]={v["text"].as<String>(),v["done"].as<bool>()};} page=0;lastPage=millis();draw();}
void reply(const char* status) {
JsonDocument d; d["status"]=status; JsonArray a=d["items"].to<JsonArray>();
for(int i=0;i<count;i++){JsonObject o=a.add<JsonObject>();o["text"]=tasks[i].text;o["done"]=tasks[i].done;}
serializeJson(d,Serial);Serial.println();
}
void command() {
JsonDocument d;
if(deserializeJson(d,line)) {reply("Invalid JSON");return;}
String cmd=d["cmd"]|"";
if(cmd=="get") {reply("loaded");return;}
if(cmd!="set" || !valid(d["items"].as<JsonArray>())) {reply("Use 0-20 tasks, 1-44 printable ASCII characters each");return;}
String saved;serializeJson(d["items"],saved);
if(!storage) {reply("NVS unavailable");return;}
if(saved!=prefs.getString("list","")) {
if(prefs.putString("list",saved)!=saved.length()) {reply("Save failed");return;}
}
load(d["items"].as<JsonArray>());reply("saved");
}
// A gesture is committed only on release, so swiping never ticks a task.
void handleTouch(){
static bool down=false;
static int sx=0,sy=0,ex=0,ey=0,travel=0;
static uint32_t poll=0,started=0,lastSeen=0;
if(millis()-poll<12)return;
poll=millis();
if(touch.touched()){
TS_Point p=touch.getPoint();
int x=constrain(map(p.x,200,3700,0,319),0L,319L);
int y=constrain(map(p.y,240,3800,0,239),0L,239L);
if(!down){sx=x;sy=y;travel=0;started=millis();down=true;}
ex=x;ey=y;travel=max(travel,abs(x-sx)+abs(y-sy));lastSeen=millis();
lastPage=millis();
return;
}
if(!down || millis()-lastSeen<60)return;
down=false;lastPage=millis();feedbackUntil=millis()+320;
int dx=ex-sx,dy=ey-sy;
bool tap=travel<22;
if(deleting>=0){
if(tap && sy>=145 && sy<185){
int index=deleting;deleting=-1;
if(sx>=170 && sx<290){
Task removed=tasks[index];
for(int j=index;j<count-1;j++)tasks[j]=tasks[j+1];count--;
if(persist()){
undoTask=removed;undoIndex=index;undoUntil=millis()+5000;
focus=-1;page=min(page,max(0,(count-1)/4));
}else{
for(int j=count;j>index;j--)tasks[j]=tasks[j-1];tasks[index]=removed;count++;reply("Save failed");
}
}
draw();
}
return;
}
if(focus>=0){
if(tap && sy<45){focus=-1;draw();}
else if(tap && sy>=181 && sy<224){
tasks[focus].done=!tasks[focus].done;
if(!persist()){tasks[focus].done=!tasks[focus].done;reply("Save failed");}draw();
}
return;
}
if(tap && undoIndex>=0 && sy>=208){
if(sx>=220 && count<20){
for(int j=count;j>undoIndex;j--)tasks[j]=tasks[j-1];tasks[undoIndex]=undoTask;count++;
if(persist()){page=undoIndex/4;undoIndex=-1;}
else{for(int j=undoIndex;j<count-1;j++)tasks[j]=tasks[j+1];count--;reply("Save failed");}
draw();
}return;
}
int pages=max(1,(count+3)/4);
if(abs(dx)>55 && abs(dx)>abs(dy)*2){
if(pages>1){page=(page+(dx<0?1:pages-1))%pages;draw();}
}else if(tap && sy>=66 && sy<210 && sx>=12 && sx<308){
int row=(sy-66)/36,i=page*4+row;
if(row<4 && i<count && (sy-66)%36<33){
if(millis()-started>=650){deleting=i;}
else if(sx>=47){focus=i;}
else{
tasks[i].done=!tasks[i].done;
if(!persist()){tasks[i].done=!tasks[i].done;reply("Save failed");}
}
draw();
}
}
}
void setup(){
Serial.begin(115200);line.reserve(4096);
tft.init();tft.setRotation(1);tft.invertDisplay(false);
SPI.begin(25,39,32,33);touch.begin();touch.setRotation(1);
canvas.setColorDepth(16);hasCanvas=canvas.createSprite(320,240)!=nullptr;
// Keep buffered drawing even if a full 16-bit frame cannot be allocated.
if(!hasCanvas){canvas.setColorDepth(8);hasCanvas=canvas.createSprite(320,240)!=nullptr;}
storage=prefs.begin("todo",false);
JsonDocument d;String s=storage?prefs.getString("list","[]"):"[]";
if(!deserializeJson(d,s) && valid(d.as<JsonArray>())) load(d.as<JsonArray>()); else draw();
}
void loop(){
for(int n=0;n<256 && Serial.available();n++) {
char c=Serial.read();
if(c=='\n') {if(overflow) reply("Message too long");else if(line.length()) command();line="";overflow=false;}
else if(c!='\r' && !overflow) {if(line.length()<4096)line+=c;else overflow=true;}
}
if(undoIndex>=0 && (int32_t)(millis()-undoUntil)>=0){undoIndex=-1;draw();}
handleTouch();
if(feedbackUntil && millis()-nextFeedback>=35){
nextFeedback=millis();draw();
if((int32_t)(millis()-feedbackUntil)>=0)feedbackUntil=0;
}
delay(2);
}Download project files
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.




