Community project
ESP32-3248S035C iphone with text
This project turns an ESP32-3248S035C display module into a functional iPhone-like interface with text messaging capabilities. The 3.5-inch touchscreen, SD card slot, and integrated touch controller are all built into the module, making assembly straightforward—just wire power, ground, and the SD card pins, then deploy the firmware.
The guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions. Builders will learn how to set up the launcher gesture, navigate between app pages, and get the messaging system running. The firmware includes support for multiple apps (notes, calculator, weather, games, and more) and WiFi connectivity for fetching data and sending messages.
Wiring diagram
Assemble it in 4 steps
1. Keep the screen wiring unchanged
Leave the screen assembled exactly as it is. The new page switch uses the built-in touch panel, so it does not need any new wires.
- Do not attach anything to GPIO32 or GPIO33; they are already connected inside the screen to its touch panel.
- Extra wires on the touch connections can stop the screen from noticing your finger.
2. Use the launcher gesture
On the main app screen, touch an empty part of the app area or begin over an icon, then slide your finger clearly left or right before lifting it. A left slide shows the next app page; a right slide returns to the first page.
- Move your finger about the width of one app icon before lifting it so the screen knows you meant to change pages.
- A short touch without sliding still opens the app you touched.
- Do not press and hold while expecting a page change; slide sideways in one smooth motion.
3. Check the page dots
Look at the two small dots just above the bottom row. The larger white dot tells you which app page you are viewing.
- The bottom dock stays in place while the app pages change.
4. Power and deploy
Plug the screen into USB power. Then use Schematik’s Deploy button to put the updated launcher onto the screen.
- After deploying, return to the main app screen before testing the swipe.
- Keep the screen on a steady surface while plugging in USB so the connector is not strained.
Deploy the firmware
#include <Arduino.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <nvs_flash.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <time.h>
#include <TFT_eSPI.h>
#include <TAMC_GT911.h>
#include "messinger.h"
// ESP32-3248S035C: display, touch, and card slot are built into this screen.
#define SD_CS_PIN 5
#define SD_SCK_PIN 18
#define SD_MISO_PIN 19
#define SD_MOSI_PIN 23
#define TOUCH_SDA_PIN 33
#define TOUCH_SCL_PIN 32
#define TOUCH_IRQ_PIN 21
#define TOUCH_RESET_PIN 25
#define TFT_BACKLIGHT_PIN 27
enum Page { HOME, SETTINGS, BROWSER, STORE, PHOTOS, PIXEL, FILES, SCREEN_TIME, DEVICE, CHESS, CHECKERS, SUDOKU, GAME_WIFI, MESSENGER, CALC, NOTES, WEATHER, CLOCKAPP, NOTIFICATIONS };
enum GameType { CHESS_GAME, CHECKERS_GAME, SUDOKU_GAME };
enum GameMode { TWO_PLAYER_MODE, AI_OFF_MODE, WIFI_MODE };
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
// Forward declarations
void pixelPushUndo();
void pixelPushRedo();
void drawNoteInput();
void drawNoteKeyboard();
uint16_t pixelColor(int i);
void pixelSnapshot(uint16_t *to);
void pixelFlood(int sx,int sy,uint16_t replacement);
void drawPixelCell(int index);
void drawPixelCanvas();
void pixelRestoreCanvas(const uint16_t *snapshot);
void pixelStrokeTo(int px, int py);
void drawPixelPickControl();
void drawPixelEraseControl();
void drawPixelUndoControl();
void drawPixelRedoControl();
void savePixelArt();
void savePixelName(int slot);
void drawBrowserQuery();
void drawBrowserResult();
void addNotification(const String &text);
bool readPhoto(int slot, uint16_t pixels[1024], uint8_t &size);
String jsonValue(const String &s,const String &key);
bool inside(int x,int y);
bool sameChessSide(char a,char b);
bool chessClear(int fx,int fy,int tx,int ty);
bool chessAttacked(int tx,int ty,bool byWhite);
bool chessInCheck(bool white);
bool chessShapeOK(int fx,int fy,int tx,int ty);
bool chessLegal(int fx,int fy,int tx,int ty);
bool chessHasMove(bool white);
void chessStatus();
void checkersStatus();
void playChessMove(int fx,int fy,int tx,int ty,bool transmit);
bool checkerRed(char p);
bool checkerCaptureAt(int fx,int fy);
bool checkersAnyCapture(bool red);
bool checkerMoveOK(int fx,int fy,int tx,int ty);
bool checkerHasMove(bool red);
void playCheckerMove(int fx,int fy,int tx,int ty,bool transmit);
void doAiMove();
void doChessAiMove();
void doCheckersAiMove();
void drawAiPrompt();
void drawAiReply();
String makeAiReply(const String &prompt);
String simpleMathReply(String question);
int spellingDistance(const String &a, const String &b);
String correctSpelling(const String &text);
String urlEncode(const String &text);
String jsonStringValue(const String &json, const String &key);
String lookupFact(const String &prompt);
void label(const String &s, int x, int y, uint16_t fg, uint16_t bg, int size);
void roundButton(int x,int y,int w,int h,const String &s,uint16_t fill,uint16_t fg);
void nav(const String &title);
void card(int x,int y,int color,const String &name,const String &glyph);
void drawSettings();
void drawBrowser();
void drawStore();
void drawPhotos();
void drawFiles();
void drawScreenTime();
void drawDevice();
void drawCalc();
void drawCalcDisplay();
void calculatorKey(const String &key);
void drawNotes();
void drawNotifications();
void refreshNotificationAges();
void drawHomeNotificationBell();
void drawClock();
void drawWeather();
void refreshWeather();
void drawPhotoSlot(int slot, int x, int y, int boxW, int boxH);
void drawPixelStudio();
void drawPixelName();
void refreshClock();
void beginNetworkTime();
String networkTimeText(bool includeSeconds);
void drawHomeClock();
void resetGames();
void chessSquare(int x,int y);
void checkerSquare(int x,int y);
void gameTop(const String&s);
void drawGameWifi();
void sendMove(int fx,int fy,int tx,int ty);
void chessTap(int x,int y);
void countCheckers();
void checkerTap(int x,int y);
void receiveMove();
void tap(int x,int y);
TFT_eSPI tft;
TAMC_GT911 touch(TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_IRQ_PIN, TOUCH_RESET_PIN, 320, 480);
bool sdCardReady = false;
Page page = HOME;
GameType gameType = CHESS_GAME;
GameMode gameMode = TWO_PLAYER_MODE;
int homeScreen = 0;
bool locked = false;
uint32_t liftedAt = 0;
uint32_t launchedAt = 0;
// Home-screen touches wait until the finger lifts, so a left/right swipe never
// accidentally opens the icon where the swipe began.
bool homeSwipeTracking = false;
int homeSwipeStartX = 0, homeSwipeStartY = 0;
const uint16_t GAME_PORT = 3334;
WiFiServer gameServer(GAME_PORT);
WiFiClient gamePeer;
bool hosting = false, joining = false;
uint32_t retryAt = 0;
uint32_t aiMoveAt = 0;
String savedIp = "192.168.1.96";
char chess[8][8], checkers[8][8];
// Medium-to-hard presets. A new shared round rotates to a different full map.
const uint8_t sudokuPuzzles[3][81] = {
{5,3,0,0,7,0,0,0,0,6,0,0,1,9,5,0,0,0,0,9,8,0,0,0,0,6,0,8,0,0,0,6,0,0,0,3,4,0,0,8,0,3,0,0,1,7,0,0,0,2,0,0,0,6,0,6,0,0,0,0,2,8,0,0,0,0,4,1,9,0,0,5,0,0,0,0,8,0,0,7,9},
{0,0,0,2,6,0,7,0,1,6,8,0,0,7,0,0,9,0,1,9,0,0,0,4,5,0,0,8,2,0,1,0,0,0,4,0,0,0,4,6,0,2,9,0,0,0,5,0,0,0,3,0,2,8,0,0,9,3,0,0,0,7,4,0,4,0,0,5,0,0,3,6,7,0,3,0,1,8,0,0,0},
{0,2,0,6,0,8,0,0,0,5,8,0,0,0,9,7,0,0,0,0,0,0,4,0,0,0,0,3,7,0,0,0,0,5,0,0,6,0,0,0,0,0,0,0,4,0,0,8,0,0,0,0,1,3,0,0,0,0,2,0,0,0,0,0,9,8,0,0,0,3,6,0,0,0,6,0,0,3,0,5,0}
};
const uint8_t sudokuAnswers[3][81] = {
{5,3,4,6,7,8,9,1,2,6,7,2,1,9,5,3,4,8,1,9,8,3,4,2,5,6,7,8,5,9,7,6,1,4,2,3,4,2,6,8,5,3,7,9,1,7,1,3,9,2,4,8,5,6,9,6,1,5,3,7,2,8,4,2,8,7,4,1,9,6,3,5,3,4,5,2,8,6,1,7,9},
{4,3,5,2,6,9,7,8,1,6,8,2,5,7,1,4,9,3,1,9,7,8,3,4,5,6,2,8,2,6,1,9,5,3,4,7,3,7,4,6,8,2,9,1,5,9,5,1,7,4,3,6,2,8,5,1,9,3,2,6,8,7,4,2,4,8,9,5,7,1,3,6,7,6,3,4,1,8,2,5,9},
{1,2,3,6,7,8,9,4,5,5,8,4,2,3,9,7,6,1,9,6,7,1,4,5,3,2,8,3,7,2,4,6,1,5,8,9,6,9,1,5,8,2,4,3,7,4,5,8,7,9,3,6,1,2,8,3,5,9,2,6,1,7,4,2,1,9,8,5,4,3,6,7,7,4,6,3,1,7,2,5,8}
};
uint8_t sudokuPuzzle[81], sudokuAnswer[81], sudokuBoard[81];
int sudokuMap = 0;
int sudokuCell = -1, sudokuMistakes = 0;
bool sudokuStarted = false;
int pickedX = -1, pickedY = -1;
bool whiteTurn = true, redTurn = true, gameDone = false;
String result = "";
String noteText = "My Note";
bool noteCaps = false;
bool noteNumbers = false;
String calc = "0";
float calcStored = 0.0f;
char calcOperation = 0;
bool calcFreshNumber = true;
String aiPrompt = "";
String aiReply = "Ask about this ESP phone, Wi-Fi, games, notes, weather, or time.";
bool aiCaps = false;
bool aiNumbers = false;
int storeNotice = -1;
int selectedPhoto = -1;
String weatherPlace = "";
String weatherNow = "Tap REFRESH to load weather";
String weatherHigh = "--";
String weatherLow = "--";
int weatherCode = -1;
// Pixel Studio edits a 16 by 16 picture and saves it in the existing
// size-byte + RGB565 format used by Photos and Messenger.
// Keep up to seven complete drawing changes. Each undo step has a matching redo step.
uint16_t pixelCanvas[256], pixelUndo[7][256], pixelRedo[7][256];
uint8_t pixelUndoCount = 0, pixelRedoCount = 0;
uint16_t pixelSelected = TFT_RED;
String pixelName = "New picture";
bool pixelErase = false, pixelEye = false, pixelNaming = false, pixelPalette = false;
bool pixelRenameExisting = false;
// Pixel-art names use the same Shift / 123 / SPACE / BACK keyboard as text chat.
bool pixelNameCaps = false, pixelNameNumbers = false;
bool pixelStrokeActive = false;
int pixelLastX = -1, pixelLastY = -1;
int fileGalleryPage = 0;
int fileGallerySelected = -1;
const int PIXEL_ART_SLOTS = 20;
const int MAX_NOTIFICATIONS = 20;
String notificationTexts[MAX_NOTIFICATIONS];
uint32_t notificationReceivedAt[MAX_NOTIFICATIONS];
int notificationCount = 1;
int unreadNotificationCount = 1;
uint32_t lastNotificationDraw = 0;
uint32_t lastClockDraw = 0;
uint32_t lastHomeClockDraw = 0;
uint32_t lastNtpAttempt = 0;
bool networkTimeReady = false;
// Keep a separate alert for every received message or save result.
void addNotification(const String &text) {
if (notificationCount >= MAX_NOTIFICATIONS) {
for (int i = 0; i < MAX_NOTIFICATIONS - 1; i++) {
notificationTexts[i] = notificationTexts[i + 1];
notificationReceivedAt[i] = notificationReceivedAt[i + 1];
}
notificationCount = MAX_NOTIFICATIONS - 1;
}
notificationTexts[notificationCount] = text;
notificationReceivedAt[notificationCount] = millis();
notificationCount++;
unreadNotificationCount++;
// A new alert changes the badge immediately, even while the launcher is open.
// The alert list itself is redrawn only when its contents changed.
if (page == HOME) drawHomeNotificationBell();
else if (page == NOTIFICATIONS) drawNotifications();
}
void drawHome();
void open(Page p);
void drawChess(bool full=true);
void drawCheckers(bool full=true);
void drawSudoku(bool full=true);
void sudokuTap(int x,int y);
void sendSudokuState();
void label(const String &s, int x, int y, uint16_t fg, uint16_t bg, int size=1) { tft.setTextColor(fg,bg); tft.setTextSize(size); tft.setCursor(x,y); tft.print(s); }
void roundButton(int x,int y,int w,int h,const String &s,uint16_t fill,uint16_t fg=TFT_WHITE) { tft.fillRoundRect(x,y,w,h,12,fill); label(s,x+(w-(int)s.length()*6)/2,y+h/2-4,fg,fill); }
void nav(const String &title) {
// Each app starts on an opaque black page: launcher icons never show underneath it.
tft.fillScreen(TFT_BLACK);
tft.fillRect(0,0,320,56,TFT_BLACK);
tft.drawFastHLine(0,55,320,tft.color565(48,48,52));
roundButton(8,10,58,33,"HOME",tft.color565(35,35,39));
label("<",15,18,TFT_WHITE,tft.color565(35,35,39),2);
label(title,78,18,TFT_WHITE,TFT_BLACK,2);
}
void card(int x,int y,int color,const String &name,const String &glyph) {
// Phone-style app tile: a clean rounded square, a thin pale edge, and a small
// lower shadow. This follows the colorful icon-grid look without reusing any
// company logo artwork.
tft.fillRoundRect(x+3,y+5,62,62,17,tft.color565(8,27,50));
tft.fillRoundRect(x-1,y-1,64,64,17,tft.color565(248,251,255));
tft.fillRoundRect(x,y,62,62,16,color);
tft.drawRoundRect(x,y,62,62,16,tft.color565(225,246,255));
// A narrow shine along the top edge makes the tile look layered, not flat.
tft.drawRoundRect(x+3,y+3,56,56,13,tft.color565(255,255,255));
if (name == "Chess") {
// A white king and a black pawn make the Chess icon recognizable at a glance.
tft.fillRect(x+20,y+17,22,4,TFT_WHITE);
tft.fillCircle(x+31,y+25,6,TFT_WHITE);
tft.fillTriangle(x+22,y+42,x+40,y+42,x+35,y+28,TFT_WHITE);
tft.fillRoundRect(x+18,y+43,26,6,3,TFT_WHITE);
tft.fillCircle(x+46,y+43,7,TFT_BLACK);
tft.fillRect(x+38,y+49,16,4,TFT_BLACK);
} else if (name == "Checkers") {
// Red and blue counters on a tiny board make the Checkers icon distinct.
for(int r=0;r<3;r++) for(int c=0;c<3;c++) {
uint16_t sq=((r+c)&1)?tft.color565(235,235,235):tft.color565(65,65,72);
tft.fillRect(x+14+c*12,y+22+r*10,12,10,sq);
}
tft.fillCircle(x+20,y+28,7,TFT_RED);
tft.drawCircle(x+20,y+28,5,TFT_YELLOW);
tft.fillCircle(x+43,y+47,7,TFT_BLUE);
tft.drawCircle(x+43,y+47,5,TFT_CYAN);
} else {
// Each tile has a deliberately different, high-contrast pictogram. The
// symbol is drawn directly on the colored square, like a modern app grid;
// no black box or pale oval is placed behind its artwork.
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE,color);
tft.setTextSize(3);
if(name=="Messenger" || name=="Chat") { /* layered blue chat bubble */ tft.fillCircle(x+31,y+39,20,tft.color565(20,108,245)); tft.fillRoundRect(x+13,y+26,36,24,10,TFT_WHITE); tft.fillTriangle(x+20,y+46,x+19,y+54,x+28,y+49,TFT_WHITE); tft.fillCircle(x+23,y+38,2,tft.color565(20,108,245)); tft.fillCircle(x+31,y+38,2,tft.color565(20,108,245)); tft.fillCircle(x+39,y+38,2,tft.color565(20,108,245)); }
else if(name=="AI Chat" || name=="AI") { /* centered black tile with a balanced white geometric AI mark */
const uint16_t violet=tft.color565(120,78,246), blue=tft.color565(50,190,255), glow=tft.color565(198,172,255);
// A new luminous assistant orb: centered, colorful, and separate from the old black mark.
tft.fillCircle(x+31,y+39,22,tft.color565(72,43,170));
tft.fillCircle(x+31,y+39,18,violet);
tft.fillCircle(x+31,y+39,13,blue);
tft.fillCircle(x+27,y+34,6,glow);
tft.fillCircle(x+27,y+34,3,TFT_WHITE);
tft.drawLine(x+20,y+48,x+25,y+43,TFT_WHITE);
tft.drawLine(x+37,y+46,x+42,y+51,TFT_WHITE);
tft.fillCircle(x+19,y+49,3,TFT_WHITE);
tft.fillCircle(x+43,y+52,3,TFT_WHITE);
tft.drawCircle(x+31,y+39,22,TFT_WHITE);
// Old black-tile outline removed.
// Old geometric logo strokes removed.
// Keep all arms inside one centered 36-pixel square so the mark does not
// lean down or right in either the launcher grid or the bottom dock.
//
//
//
//
//
//
//
//
//
//
}
else if(name=="App Store") { /* three luminous strokes form an app-store A */ tft.fillCircle(x+31,y+39,20,tft.color565(72,92,240)); tft.drawLine(x+20,y+49,x+31,y+24,TFT_WHITE); tft.drawLine(x+31,y+24,x+44,y+49,TFT_WHITE); tft.drawLine(x+24,y+42,x+40,y+42,TFT_WHITE); tft.fillCircle(x+20,y+49,2,TFT_WHITE); tft.fillCircle(x+44,y+49,2,TFT_WHITE); }
else if(name=="Settings" || name=="Setup") { for(int a=0;a<8;a++){float r=a*0.785f;tft.fillCircle(x+31+(int)(12*cos(r)),y+39+(int)(12*sin(r)),4,TFT_WHITE);} tft.fillCircle(x+31,y+39,12,TFT_WHITE); tft.fillCircle(x+31,y+39,5,color); }
else if(name=="Calculator") { /* a real mini calculator face, not a character tile */ tft.fillRoundRect(x+15,y+20,32,37,7,TFT_WHITE); tft.fillRoundRect(x+19,y+24,24,8,2,tft.color565(55,66,80)); for(int rr=0;rr<2;rr++)for(int cc=0;cc<3;cc++)tft.fillCircle(x+22+cc*9,y+40+rr*8,3,cc==2?tft.color565(255,146,34):tft.color565(95,105,116)); }
else if(name=="Notes") { /* cream paper with a folded corner and warm writing lines */ tft.fillRoundRect(x+16,y+19,31,38,5,tft.color565(255,246,190)); tft.fillTriangle(x+38,y+19,x+47,y+19,x+47,y+29,tft.color565(250,207,72)); tft.drawFastHLine(x+21,y+35,20,tft.color565(237,152,40)); tft.drawFastHLine(x+21,y+42,17,tft.color565(237,152,40)); tft.drawFastHLine(x+21,y+49,13,tft.color565(237,152,40)); }
else if(name=="Drawing") { /* four overlapping color petals make a gallery flower */ tft.fillCircle(x+24,y+34,11,TFT_YELLOW); tft.fillCircle(x+38,y+34,11,TFT_RED); tft.fillCircle(x+24,y+47,11,tft.color565(44,192,111)); tft.fillCircle(x+38,y+47,11,tft.color565(48,151,245)); tft.fillCircle(x+31,y+40,7,TFT_WHITE); }
else if(name=="Files" || name=="Storage") { /* a dimensional blue folder with a paper tab */ tft.fillRoundRect(x+14,y+27,35,25,5,tft.color565(47,145,242)); tft.fillRoundRect(x+17,y+22,16,11,4,tft.color565(102,190,255)); tft.fillRoundRect(x+17,y+34,29,14,3,tft.color565(114,197,255)); }
else if(name=="Clock") { /* clean black-and-white analog clock */ tft.fillCircle(x+31,y+39,20,TFT_WHITE); tft.drawCircle(x+31,y+39,18,tft.color565(40,40,45)); for(int a=0;a<12;a++){float r=a*0.5236f;tft.fillCircle(x+31+(int)(14*cos(r)),y+39+(int)(14*sin(r)),1,TFT_BLACK);} tft.drawLine(x+31,y+39,x+31,y+27,TFT_BLACK); tft.drawLine(x+31,y+39,x+41,y+44,TFT_BLACK); tft.fillCircle(x+31,y+39,2,TFT_RED); }
else if(name=="Weather") { /* glossy sun peeking through a soft cloud */ for(int a=0;a<8;a++){float r=a*0.785f;tft.drawLine(x+23+(int)(14*cos(r)),y+32+(int)(14*sin(r)),x+23+(int)(19*cos(r)),y+32+(int)(19*sin(r)),TFT_YELLOW);} tft.fillCircle(x+23,y+32,12,TFT_YELLOW); tft.fillCircle(x+39,y+43,10,TFT_WHITE); tft.fillCircle(x+30,y+45,12,TFT_WHITE); tft.fillRoundRect(x+23,y+43,28,11,6,TFT_WHITE); }
else if(name=="Screen Time") { tft.drawCircle(x+31,y+37,16,TFT_WHITE); tft.drawLine(x+31,y+37,x+31,y+25,TFT_WHITE); }
else if(name=="Device Info") { tft.fillRoundRect(x+20,y+19,22,37,4,TFT_WHITE); tft.setTextColor(color,TFT_WHITE); tft.setTextSize(2); tft.drawString("i",x+31,y+30); }
else tft.drawString(glyph,x+31,y+34);
}
// Draw the app name transparently so no black rectangle sits behind it.
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE);
tft.setTextSize(1);
tft.drawString(name,x+31,y+76);
tft.setTextDatum(TL_DATUM);
}
String networkTimeText(bool includeSeconds) {
if (!networkTimeReady) return includeSeconds ? "--:--:--" : "--:--";
struct tm timeInfo;
if (!getLocalTime(&timeInfo, 10)) { networkTimeReady = false; return includeSeconds ? "--:--:--" : "--:--"; }
char stamp[10];
strftime(stamp, sizeof(stamp), includeSeconds ? "%H:%M:%S" : "%H:%M", &timeInfo);
return String(stamp);
}
void beginNetworkTime() {
if (WiFi.status() != WL_CONNECTED || networkTimeReady || millis() - lastNtpAttempt < 10000) return;
lastNtpAttempt = millis();
// Eastern Time automatically changes between EST and daylight-saving EDT.
configTzTime("EST5EDT,M3.2.0/2,M11.1.0/2", "pool.ntp.org", "time.nist.gov");
struct tm timeInfo;
networkTimeReady = getLocalTime(&timeInfo, 1200);
}
void drawHomeClock() {
// Redraw only the transparent-looking status strip. It restores the wallpaper
// behind the clock rather than putting time or Wi-Fi inside black rectangles.
for (int y = 0; y < 38; y += 2) {
uint8_t r = 10 + (y * 2 / 38), g = 105 + (y * 8 / 38), b = 175 - (y * 4 / 38);
tft.fillRect(0, y, 320, 2, tft.color565(r, g, b));
}
tft.setTextColor(TFT_WHITE);
tft.setTextSize(2);
tft.setCursor(16, 12);
tft.print(networkTimeText(false));
tft.setTextSize(1);
tft.setCursor(78, 18);
tft.setTextColor(tft.color565(220, 245, 255));
tft.print(networkTimeReady ? "ET" : "sync");
tft.setTextColor(TFT_WHITE);
tft.setCursor(228, 15);
tft.print(WiFi.status() == WL_CONNECTED ? "Wi-Fi" : "No Wi-Fi");
// A small signal mark replaces a heavy status container.
uint16_t signal = WiFi.status() == WL_CONNECTED ? TFT_WHITE : TFT_LIGHTGREY;
tft.drawFastVLine(294, 25, 4, signal);
tft.drawFastVLine(299, 21, 8, signal);
tft.drawFastVLine(304, 17, 12, signal);
}
void drawHome() {
page=HOME;
// A layered blue/teal wallpaper gives the small screen the same clean phone feel.
// Use 16 broad wallpaper bands rather than 120 tiny strips. This preserves the
// layered blue wallpaper while greatly reducing display commands on each Home redraw.
for(int y=0;y<480;y+=16) {
uint8_t r=10+(y*22/480), g=105+(y*70/480), b=175-(y*35/480);
tft.fillRect(0,y,320,16,tft.color565(r,g,b));
}
tft.fillCircle(292,302,150,tft.color565(37,173,183));
tft.fillCircle(42,420,130,tft.color565(33,105,205));
drawHomeClock();
drawHomeNotificationBell();
const char* names[16]={"Messenger","AI Chat","App Store","Settings","Chess","Checkers","Sudoku","Calculator","Notes","Drawing","Photos","Clock","Weather","Screen Time","Storage","Device Info"};
const char* icons[16]={"M","AI","A","G","C","D","9","+","N","P","F","o","~","T","S","i"};
uint16_t colors[16]={TFT_DARKGREEN,tft.color565(91,70,230),TFT_MAGENTA,TFT_DARKGREY,TFT_BLUE,TFT_ORANGE,tft.color565(30,115,180),TFT_ORANGE,TFT_PURPLE,TFT_CYAN,TFT_DARKGREY,TFT_ORANGE,TFT_CYAN,TFT_PURPLE,TFT_DARKGREY,TFT_BLUE};
int start=homeScreen*12, count=min(12,16-start);
for(int i=0;i<count;i++){int x=17+(i%4)*77,y=78+(i/4)*103;card(x,y,colors[start+i],names[start+i],icons[start+i]);}
// The dock stays in one place like a phone, and the page control switches the remaining apps.
// A soft glass-like dock separates the always-ready apps from the wallpaper.
tft.fillRoundRect(10,395,300,74,26,tft.color565(24,83,157));
tft.drawRoundRect(10,395,300,74,26,tft.color565(170,235,255));
tft.drawFastHLine(31,399,258,tft.color565(109,194,230));
// Four 62-pixel icons with equal gaps: this is centered inside the 300-pixel dock.
card(17,401,TFT_DARKGREEN,"Chat","M");
card(92,401,tft.color565(91,70,230),"AI","AI");
card(167,401,TFT_PURPLE,"Notes","N");
card(242,401,TFT_DARKGREY,"Setup","G");
tft.fillCircle(151,385,homeScreen?4:6,TFT_WHITE);
tft.fillCircle(169,385,homeScreen?6:4,TFT_WHITE);
}
// A simple black alert circle makes notifications easy to spot on the Home screen.
void drawHomeNotificationBell() {
// Restore the small wallpaper area, then draw a black circle with a white !.
tft.fillCircle(287, 50, 16, tft.color565(19, 126, 171));
tft.fillCircle(287, 50, 14, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.drawString("!", 287, 50);
tft.setTextDatum(TL_DATUM);
if (unreadNotificationCount > 0) {
tft.fillCircle(304, 37, 10, TFT_RED);
tft.drawCircle(304, 37, 10, tft.color565(255, 177, 180));
tft.setTextDatum(MC_DATUM); tft.setTextColor(TFT_WHITE, TFT_RED); tft.setTextSize(1);
tft.drawString(unreadNotificationCount > 9 ? "9+" : String(unreadNotificationCount), 304, 37);
tft.setTextDatum(TL_DATUM);
}
}
void drawSettings(){
nav("Settings");
tft.fillRoundRect(12,69,296,34,9,tft.color565(30,30,32));
label("Search",43,80,TFT_LIGHTGREY,tft.color565(30,30,32));
tft.drawCircle(29,85,6,TFT_LIGHTGREY); tft.drawLine(33,89,38,94,TFT_LIGHTGREY);
tft.fillRoundRect(12,113,296,65,12,tft.color565(30,30,32));
tft.fillCircle(45,145,22,tft.color565(245,207,151));
label("ESP Phone",78,128,TFT_WHITE,tft.color565(30,30,32),2);
String storageLine = "Insert microSD card";
if (sdCardReady) {
float freeGb = (SD.totalBytes() - SD.usedBytes()) / 1073741824.0f;
storageLine = String(freeGb, 2) + " GB free on microSD";
}
label(storageLine,78,151,TFT_LIGHTGREY,tft.color565(30,30,32));
const char* a[]={"Wi-Fi","Display & brightness","Downloads","Storage","Screen Time","About this ESP Phone"};
const uint16_t icon[]={TFT_BLUE,TFT_ORANGE,TFT_PURPLE,TFT_GREEN,TFT_MAGENTA,TFT_DARKGREY};
for(int i=0;i<6;i++){
int y=190+i*45; uint16_t bg=tft.color565(30,30,32);
tft.fillRoundRect(12,y,296,40,9,bg); tft.fillRoundRect(21,y+7,26,26,6,icon[i]);
label(i==0?"~":"",28,y+13,TFT_WHITE,icon[i],2);
label(a[i],58,y+13,TFT_WHITE,bg); label(">",281,y+12,TFT_LIGHTGREY,bg,2);
}
}
void drawAiPrompt(){
tft.fillRoundRect(12,67,296,42,16,TFT_WHITE);
String shown=aiPrompt.length()?aiPrompt:"Ask the AI helper";
label(shown.substring(max(0,(int)shown.length()-36)),23,82,aiPrompt.length()?TFT_BLACK:TFT_DARKGREY,TFT_WHITE);
}
String urlEncode(const String &text) {
const char *hex = "0123456789ABCDEF";
String encoded;
for (size_t i = 0; i < text.length(); i++) {
uint8_t c = (uint8_t)text.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') encoded += (char)c;
else { encoded += '%'; encoded += hex[c >> 4]; encoded += hex[c & 15]; }
}
return encoded;
}
String jsonStringValue(const String &json, const String &key) {
int p = json.indexOf("\"" + key + "\":\"");
if (p < 0) return "";
p += key.length() + 4;
String value;
bool escaped = false;
for (; p < (int)json.length(); p++) {
char c = json.charAt(p);
if (escaped) {
if (c == 'n') value += ' ';
else if (c == 't') value += ' ';
else value += c;
escaped = false;
} else if (c == '\\') escaped = true;
else if (c == '\"') break;
else value += c;
}
return value;
}
String lookupFact(const String &prompt) {
if (WiFi.status() != WL_CONNECTED) return "Connect to Wi-Fi first, then tap ASK for a general answer.";
WiFiClientSecure secure;
secure.setInsecure();
HTTPClient http;
http.setTimeout(9000);
String searchUrl = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + urlEncode(prompt) + "&format=json&utf8=1";
if (!http.begin(secure, searchUrl)) return "I could not reach the answer service. Try again.";
int status = http.GET();
String search = status == 200 ? http.getString() : "";
http.end();
String title = jsonStringValue(search, "title");
if (!title.length()) return "I could not find a clear answer. Try using a few different words.";
if (!http.begin(secure, "https://en.wikipedia.org/api/rest_v1/page/summary/" + urlEncode(title))) return "I found a topic but could not open its answer.";
status = http.GET();
String summary = status == 200 ? http.getString() : "";
http.end();
String answer = jsonStringValue(summary, "extract");
if (!answer.length()) return "I found " + title + ", but it has no short answer.";
// Keep the reply card readable; the first sentence is usually the direct answer.
int end = answer.indexOf('.');
if (end > 20) answer = answer.substring(0, end + 1);
if (answer.length() > 150) answer = answer.substring(0, 147) + "...";
return answer;
}
String simpleMathReply(String question) {
// This small calculator understands a simple pair of numbers such as
// "2 * 2", "what is 8 plus 4", or "10 divided by 2" without Wi-Fi.
question.toLowerCase();
question.replace("multiplied by", "*");
question.replace("times", "*");
question.replace("divided by", "/");
question.replace("plus", "+");
question.replace("minus", "-");
int firstDigit = -1;
for (int i = 0; i < (int)question.length(); i++) {
if (isDigit(question.charAt(i)) || question.charAt(i) == '.') { firstDigit = i; break; }
}
if (firstDigit < 0) return "";
String expression = question.substring(firstDigit);
float left = 0.0f, right = 0.0f;
char operation = 0;
if (sscanf(expression.c_str(), "%f %c %f", &left, &operation, &right) != 3) return "";
// Only accept calculator operators. This prevents ordinary fact questions
// containing a year or another number from being treated as arithmetic.
if (operation != '+' && operation != '-' && operation != '*' && operation != 'x' && operation != '/') return "";
if (operation == 'x') operation = '*';
if (operation == '/' && right == 0.0f) return "You cannot divide by zero.";
float answer = left;
if (operation == '+') answer += right;
else if (operation == '-') answer -= right;
else if (operation == '*') answer *= right;
else answer /= right;
String shown = String(answer, 4);
while (shown.indexOf('.') >= 0 && shown.endsWith("0")) shown.remove(shown.length() - 1);
if (shown.endsWith(".")) shown.remove(shown.length() - 1);
return String(left, 4) + " " + operation + " " + String(right, 4) + " = " + shown;
}
int spellingDistance(const String &a, const String &b) {
// Small edit-distance check: one missing, extra, or wrong letter still matches.
int la=a.length(), lb=b.length();
if(abs(la-lb)>2) return 3;
int row[18], next[18];
for(int j=0;j<=lb;j++) row[j]=j;
for(int i=1;i<=la;i++) {
next[0]=i;
for(int j=1;j<=lb;j++) next[j]=min(min(row[j]+1,next[j-1]+1),row[j-1]+(a.charAt(i-1)==b.charAt(j-1)?0:1));
for(int j=0;j<=lb;j++) row[j]=next[j];
}
return row[lb];
}
String correctSpelling(const String &text) {
// Correct only familiar app/question words, so names and unusual fact queries stay intact.
const char* words[]={"hello","what","when","where","weather","wifi","internet","sudoku","chess","checkers","messenger","notes","clock","time","pixel","photos","files","storage","settings","divide","division","america","formed","founded","independence"};
String fixed="", token="";
for(int i=0;i<=text.length();i++) {
char ch=i<text.length()?text.charAt(i):' ';
if(isAlphaNumeric(ch)) token+=ch;
else {
if(token.length()>2) {
String lower=token; lower.toLowerCase();
int best=3; const char* match=nullptr;
for(unsigned int w=0;w<sizeof(words)/sizeof(words[0]);w++) { int d=spellingDistance(lower,words[w]); if(d<best){best=d;match=words[w];} }
if(match && best<=((lower.length()>6)?2:1)) token=String(match);
}
fixed+=token; fixed+=ch; token="";
}
}
fixed.trim();
return fixed;
}
String makeAiReply(const String &prompt){
// Correct common phone-keyboard slips before local answers or an online lookup.
String question=correctSpelling(prompt); question.trim();
String q=question; q.toLowerCase();
q.replace("amarica","america");
q.replace("amerca","america");
q.replace("forned","formed");
q.replace("foundeded","founded");
q.replace("whats ","what is ");
if(!q.length()) return "Type a question, then tap ASK.";
if(q=="hi" || q=="hello" || q=="hey" || q.startsWith("hi ") || q.startsWith("hello ")) return "Hi, how are you? What would you like to ask?";
String mathAnswer = simpleMathReply(q);
if(mathAnswer.length()) return mathAnswer;
if(q.indexOf("wifi")>=0 || q.indexOf("wi-fi")>=0 || q.indexOf("internet")>=0) return "Open Messenger to choose Wi-Fi. Saved Wi-Fi is tried first.";
if(q.indexOf("time")>=0 || q.indexOf("clock")>=0) return "The clock uses Wi-Fi time and shows Eastern Time when connected.";
if(q.indexOf("weather")>=0) return "Open Weather and tap REFRESH after Wi-Fi connects.";
if(q.indexOf("note")>=0) return "Notes save to /notes.txt on the microSD card.";
if(q.indexOf("chess")>=0 || q.indexOf("checker")>=0) return "Choose 2 PLAY, AI, or WI-FI at the top of either game.";
if(q.indexOf("mess")>=0 || q.indexOf("text")>=0) return "Messenger keeps listening for new texts and adds an alert for each one.";
if(q.indexOf("america")>=0 && (q.indexOf("formed")>=0 || q.indexOf("founded")>=0 || q.indexOf("independ")>=0 || q.indexOf("born")>=0)) return "The United States declared independence from Great Britain on July 4, 1776.";
// For general questions, use the corrected words with Wikipedia's live summary service.
return lookupFact(q);
}
void drawAiReply(){
// A taller, layered answer card keeps useful answers visible without covering the keyboard.
uint16_t bg=tft.color565(35,30,62);
tft.fillRoundRect(12,119,296,132,18,bg);
tft.fillRoundRect(22,130,34,34,10,tft.color565(100,76,245));
label("*",33,138,TFT_WHITE,tft.color565(100,76,245),2);
label("AI CHAT",68,136,TFT_CYAN,bg,2);
String first=aiReply.substring(0,min(40,(int)aiReply.length()));
String second=aiReply.length()>40?aiReply.substring(40,min(80,(int)aiReply.length())):"";
String third=aiReply.length()>80?aiReply.substring(80,min(120,(int)aiReply.length())):"";
label(first,25,174,TFT_WHITE,bg);
if(second.length()) label(second,25,194,TFT_WHITE,bg);
if(third.length()) label(third,25,214,TFT_WHITE,bg);
}
void drawBrowser(){
nav("AI Chat");
drawAiPrompt();
roundButton(252,73,48,29,"ASK",tft.color565(91,70,230));
drawAiReply();
// Match Messenger's text keyboard: Shift, 123, letters, SPACE, BACK, and SEND.
// AI Chat deliberately has no emoji key or emoji page.
tft.fillRect(0,326,320,154,TFT_LIGHTGREY);
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
for(int r=0;r<3;r++){const char* line=aiNumbers?digits[r]:letters[r];int n=strlen(line),gap=1,w=(320-(n-1)*gap)/n,x=(320-(n*(w+gap)-gap))/2,y=326+r*37;for(int i=0;i<n;i++){tft.fillRoundRect(x+i*(w+gap),y,w,36,5,TFT_WHITE);tft.drawRoundRect(x+i*(w+gap),y,w,36,5,TFT_DARKGREY);char key=line[i];if(!aiNumbers&&!aiCaps)key=tolower((unsigned char)key);label(String(key),x+i*(w+gap)+w/2-3,y+8,TFT_BLACK,TFT_WHITE,2);}}
roundButton(4,438,48,40,aiCaps?"SHIFT":"shift",TFT_BLACK);
roundButton(55,438,39,40,aiNumbers?"ABC":"123",TFT_BLACK);
roundButton(97,438,116,40,"SPACE",TFT_WHITE,TFT_BLACK);
roundButton(216,438,50,40,"BACK",TFT_BLACK);
roundButton(269,438,47,40,"ASK",tft.color565(91,70,230));
}
void drawStore(){
nav("App Store"); label("Discover",18,71,TFT_WHITE,TFT_BLACK,2);
const char* app[]={"Messenger","Chess","Checkers","Notes"}; const char* detail[]={"Chat with another ESP32","Board game with Wi-Fi play","Local or Wi-Fi board game","Keep short notes"}; uint16_t c[]={TFT_DARKGREEN,TFT_BLUE,TFT_ORANGE,TFT_PURPLE};
for(int i=0;i<4;i++){int y=99+i*72;tft.fillRoundRect(14,y,292,62,12,TFT_DARKGREY);tft.fillRoundRect(23,y+9,44,44,11,c[i]);label(app[i],78,y+10,TFT_WHITE,TFT_DARKGREY,2);label(detail[i],78,y+34,TFT_LIGHTGREY,TFT_DARKGREY);roundButton(230,y+16,60,28,storeNotice==i?"OPEN":"GET",storeNotice==i?TFT_GREEN:TFT_BLUE);}
label("Built-in apps need no download.",55,401,TFT_LIGHTGREY,TFT_BLACK); }
bool readPhoto(int slot, uint16_t pixels[1024], uint8_t &size){if(!sdCardReady)return false;String path=String("/pixel_art_")+slot+".bin";File f=SD.open(path.c_str(),FILE_READ);if(!f)return false;bool ok=f.read(&size,1)==1&&(size==8||size==16||size==32);if(ok)ok=f.read((uint8_t*)pixels,size*size*2)==size*size*2;f.close();return ok;}
void drawPhotoSlot(int slot,int x,int y,int boxW,int boxH){tft.fillRoundRect(x,y,boxW,boxH,8,TFT_DARKGREY);uint16_t pixels[1024];uint8_t size=0;if(!readPhoto(slot,pixels,size)){label("EMPTY",x+12,y+boxH/2-4,TFT_LIGHTGREY,TFT_DARKGREY);return;}int scale=max(1,min((boxW-10)/size,(boxH-24)/size));int pw=size*scale,ph=size*scale,px=x+(boxW-pw)/2,py=y+4;for(int r=0;r<size;r++)for(int c=0;c<size;c++)tft.fillRect(px+c*scale,py+r*scale,scale,scale,pixels[r*size+c]);String title=String(slot+1);File n=SD.open((String("/pixel_art_")+slot+".txt").c_str(),FILE_READ);if(n){title=n.readString();title.trim();n.close();}label(title.substring(0,16),x+7,y+boxH-15,TFT_WHITE,TFT_DARKGREY);}
void drawPhotos(){nav("Drawing");label("Saved pixel art",72,68,TFT_WHITE,TFT_BLACK,2);if(!sdCardReady){label("Insert a microSD card to see saved art.",20,112,TFT_ORANGE,TFT_BLACK);return;}if(selectedPhoto>=0){drawPhotoSlot(selectedPhoto,36,95,248,248);roundButton(72,370,176,40,"BACK TO GALLERY",TFT_BLUE);roundButton(72,420,176,36,"DELETE THIS ART",TFT_RED);return;}for(int i=0;i<6;i++)drawPhotoSlot(i,15+(i%2)*150,100+(i/2)*96,140,86);roundButton(72,405,176,38,"NEW PIXEL ART",TFT_PURPLE);}
uint16_t pixelColor(int i){
// Nine hue rows run bright-to-dark (red through pink); row ten is gray.
if(i>=144){uint8_t v=(i-144)*255/15;return tft.color565(v,v,v);}
uint8_t band=i/16,shade=i%16;float h=band*40.0f;float v=1.0f-shade*0.045f;float s=0.82f;float c=v*s,x=c*(1-fabs(fmod(h/60.0f,2)-1)),m=v-c;float r=0,g=0,b=0;
if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;}
return tft.color565((uint8_t)((r+m)*255),(uint8_t)((g+m)*255),(uint8_t)((b+m)*255));
}
void pixelSnapshot(uint16_t *to){memcpy(to,pixelCanvas,sizeof(pixelCanvas));}
void pixelPushUndo(){
if(pixelUndoCount >= 7){
memmove(pixelUndo[0], pixelUndo[1], sizeof(pixelUndo[0]) * 6);
pixelUndoCount = 6;
}
pixelSnapshot(pixelUndo[pixelUndoCount++]);
pixelRedoCount = 0;
}
void pixelPushRedo(){
if(pixelRedoCount >= 7){
memmove(pixelRedo[0], pixelRedo[1], sizeof(pixelRedo[0]) * 6);
pixelRedoCount = 6;
}
// Save the picture currently on screen before Undo restores an older one.
pixelSnapshot(pixelRedo[pixelRedoCount++]);
}
// These helpers touch only the canvas square or button that actually changed.
void drawPixelCell(int index){int px=index%16,py=index/16;tft.fillRect(8+px*19,65+py*19,19,19,pixelCanvas[index]);}
void drawPixelCanvas(){for(int i=0;i<256;i++)drawPixelCell(i);}
// Restore a history picture without repainting the whole Pixel Studio page.
// Only cells whose color really changed are sent to the display.
void pixelRestoreCanvas(const uint16_t *snapshot){
for(int i=0;i<256;i++){
if(pixelCanvas[i]!=snapshot[i]){
pixelCanvas[i]=snapshot[i];
drawPixelCell(i);
}
}
}
void drawPixelPickControl(){roundButton(140,383,38,36,"PICK",pixelEye?TFT_GREEN:TFT_DARKGREY);}
void drawPixelEraseControl(){roundButton(181,383,47,36,"ERASE",pixelErase?TFT_RED:TFT_DARKGREY);}
void drawPixelUndoControl(){roundButton(48,383,43,36,"UNDO",TFT_DARKGREY);}
void drawPixelRedoControl(){roundButton(94,383,43,36,"REDO",TFT_DARKGREY);}
void pixelStrokeTo(int px,int py){
if(px<0||px>15||py<0||py>15)return;
if(pixelEye){int index=py*16+px;pixelSelected=pixelCanvas[index];pixelEye=false;pixelStrokeActive=false;tft.fillRoundRect(11,385,32,32,7,pixelSelected);tft.drawRoundRect(11,385,32,32,7,TFT_WHITE);drawPixelPickControl();return;}
if(!pixelStrokeActive){pixelPushUndo();pixelStrokeActive=true;pixelLastX=px;pixelLastY=py;}
int x0=pixelLastX,y0=pixelLastY,dx=abs(px-x0),sx=x0<px?1:-1,dy=-abs(py-y0),sy=y0<py?1:-1,err=dx+dy;
while(true){int index=y0*16+x0;uint16_t color=pixelErase?TFT_BLACK:pixelSelected;if(pixelCanvas[index]!=color){pixelCanvas[index]=color;drawPixelCell(index);}if(x0==px&&y0==py)break;int e2=2*err;if(e2>=dy){err+=dy;x0+=sx;}if(e2<=dx){err+=dx;y0+=sy;}}
pixelLastX=px;pixelLastY=py;
}
void pixelFlood(int sx,int sy,uint16_t replacement){uint16_t old=pixelCanvas[sy*16+sx];if(old==replacement)return;pixelCanvas[sy*16+sx]=replacement;bool changed=true;while(changed){changed=false;for(int y=0;y<16;y++)for(int x=0;x<16;x++)if(pixelCanvas[y*16+x]==old){bool near=(x&&pixelCanvas[y*16+x-1]==replacement)||(x<15&&pixelCanvas[y*16+x+1]==replacement)||(y&&pixelCanvas[(y-1)*16+x]==replacement)||(y<15&&pixelCanvas[(y+1)*16+x]==replacement);if(near){pixelCanvas[y*16+x]=replacement;changed=true;}}}}
void drawPixelStudio(){
nav(pixelPalette ? "Pixel colors" : "Pixel Studio");
if(pixelPalette){
label("160 colors - tap one to paint with it",23,68,TFT_LIGHTGREY,TFT_BLACK);
for(int i=0;i<160;i++){int x=(i%16)*20,y=92+(i/16)*26;uint16_t c=pixelColor(i);tft.fillRect(x,y,20,26,c);if(c==pixelSelected)tft.drawRect(x+1,y+1,18,24,TFT_WHITE);}
label("Each color row goes light to dark. Last row: black to white.",10,365,TFT_LIGHTGREY,TFT_BLACK);
roundButton(55,410,210,42,"BACK TO PIXEL ART",TFT_BLUE);
return;
}
// Draw a clean 16 by 16 canvas with no visible white grid lines.
// Individual taps repaint only one 19-pixel square below.
tft.fillRect(8,65,304,304,TFT_BLACK);
drawPixelCanvas();
tft.fillRoundRect(5,377,310,48,10,tft.color565(42,42,47));
tft.fillRoundRect(11,385,32,32,7,pixelSelected);tft.drawRoundRect(11,385,32,32,7,TFT_WHITE);
drawPixelUndoControl();
drawPixelRedoControl();
drawPixelPickControl();
drawPixelEraseControl();
roundButton(231,383,38,36,"FILL",TFT_BLUE);
roundButton(272,383,40,36,"COLOR",TFT_PURPLE);
roundButton(8,434,70,37,"SAVE",TFT_GREEN);
roundButton(82,434,82,37,"PHOTOS",TFT_BLUE);
roundButton(168,434,55,37,"NEW",TFT_PURPLE);
label(pixelName.substring(0,14),229,448,TFT_WHITE,TFT_BLACK);
}
void drawPixelName(){
nav("Name picture");
tft.fillRoundRect(12,66,296,52,12,TFT_WHITE);
String shown=pixelName.substring(max(0,(int)pixelName.length()-28));
label(shown,23,84,TFT_BLACK,TFT_WHITE,2);
// Same key positions and modes as the Messenger text keyboard.
tft.fillRect(0,326,320,154,TFT_LIGHTGREY);
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
for(int r=0;r<3;r++){const char* line=pixelNameNumbers?digits[r]:letters[r];int n=strlen(line),gap=1,w=(320-(n-1)*gap)/n,x=(320-(n*(w+gap)-gap))/2,y=326+r*37;for(int i=0;i<n;i++){tft.fillRoundRect(x+i*(w+gap),y,w,36,5,TFT_WHITE);tft.drawRoundRect(x+i*(w+gap),y,w,36,5,TFT_DARKGREY);char key=line[i];if(!pixelNameNumbers&&!pixelNameCaps)key=tolower((unsigned char)key);label(String(key),x+i*(w+gap)+w/2-3,y+8,TFT_BLACK,TFT_WHITE,2);}}
roundButton(4,438,48,40,pixelNameCaps?"SHIFT":"shift",TFT_BLACK);
roundButton(55,438,39,40,pixelNameNumbers?"ABC":"123",TFT_BLACK);
roundButton(97,438,116,40,"SPACE",TFT_WHITE,TFT_BLACK);
roundButton(216,438,50,40,"BACK",TFT_BLACK);
roundButton(269,438,47,40,"SAVE",TFT_GREEN);
}
void savePixelArt(){
if(!sdCardReady){addNotification("Insert microSD to save pixel art");return;}
int slot=-1;
for(int i=0;i<PIXEL_ART_SLOTS;i++){uint16_t p[1024];uint8_t s;if(!readPhoto(i,p,s)){slot=i;break;}}
if(slot<0){addNotification("Pixel Gallery is full - delete art first");return;}
String path=String("/pixel_art_")+slot+".bin";
SD.remove(path.c_str());
File f=SD.open(path.c_str(),FILE_WRITE);
if(!f){addNotification("Could not save pixel art");return;}
uint8_t size=16; f.write(&size,1); f.write((uint8_t*)pixelCanvas,512); f.close();
String namePath=String("/pixel_art_")+slot+".txt";
SD.remove(namePath.c_str());
File n=SD.open(namePath.c_str(),FILE_WRITE);if(n){n.print(pixelName);n.close();}
addNotification("Saved "+pixelName+" to Pixel Gallery");
}
void savePixelName(int slot){
if(!sdCardReady || slot<0 || slot>=PIXEL_ART_SLOTS)return;
String namePath=String("/pixel_art_")+slot+".txt";
SD.remove(namePath.c_str());
File n=SD.open(namePath.c_str(),FILE_WRITE);
if(n){n.print(pixelName);n.close();addNotification("Renamed pixel art to "+pixelName);}
else addNotification("Could not rename pixel art");
}
void drawFiles(){
nav("Photos");
if(!sdCardReady){label("microSD card not found",42,90,TFT_RED,TFT_BLACK,2);label("Insert the card, then open Files again.",24,128,TFT_LIGHTGREY,TFT_BLACK);return;}
if(fileGallerySelected>=0){
label("Pixel Gallery",85,68,TFT_WHITE,TFT_BLACK,2);
drawPhotoSlot(fileGallerySelected,36,95,248,248);
roundButton(20,370,86,38,"BACK",TFT_BLUE);
roundButton(116,370,88,38,"RENAME",TFT_PURPLE);
roundButton(214,370,86,38,"DELETE",TFT_RED);
roundButton(56,423,208,38,"NEW PIXEL ART",TFT_GREEN);
return;
}
label("Pixel Gallery",98,68,TFT_WHITE,TFT_BLACK,2);
float freeGb=(SD.totalBytes()-SD.usedBytes())/1073741824.0f;
label(String(freeGb,2)+" GB free",116,91,TFT_GREEN,TFT_BLACK);
int first=fileGalleryPage*6;
for(int i=0;i<6;i++)drawPhotoSlot(first+i,15+(i%2)*150,110+(i/2)*91,140,82);
roundButton(12,392,62,34,"PREV",fileGalleryPage>0?TFT_BLUE:TFT_DARKGREY);
roundButton(82,392,156,34,"NEW PIXEL ART",TFT_GREEN);
roundButton(246,392,62,34,"NEXT",fileGalleryPage<((PIXEL_ART_SLOTS-1)/6)?TFT_BLUE:TFT_DARKGREY);
label(String("Page ")+(fileGalleryPage+1)+" of "+String((PIXEL_ART_SLOTS+5)/6),106,439,TFT_LIGHTGREY,TFT_BLACK);
}
void drawScreenTime(){nav("Screen Time");label("Today",126,90,TFT_WHITE,TFT_BLACK,2); uint32_t mins=(millis()-launchedAt)/60000; label(String(mins)+" minutes",88,142,TFT_GREEN,TFT_BLACK,4);label("Time this phone has been switched on",43,203,TFT_LIGHTGREY,TFT_BLACK);}
void drawDevice(){nav("About");label("ESP32 Screen Phone",52,90,TFT_WHITE,TFT_BLACK,2);label("320 x 480 touch display",55,136,TFT_LIGHTGREY,TFT_BLACK);label("Wi-Fi: "+String(WiFi.status()==WL_CONNECTED?WiFi.localIP().toString():"Not connected"),23,169,TFT_GREEN,TFT_BLACK);label(sdCardReady?"Storage: microSD available":"Storage: insert microSD",23,203,sdCardReady?TFT_GREEN:TFT_ORANGE,TFT_BLACK);}
void drawCalcDisplay(){
// Only this display rectangle changes when a calculator key is tapped.
tft.fillRoundRect(12,68,296,67,12,TFT_BLACK);
String shown=calc.length()?calc:"0";
if(shown.length()>15)shown=shown.substring(shown.length()-15);
tft.setTextDatum(TR_DATUM);
tft.setTextColor(TFT_WHITE,TFT_BLACK);
tft.setTextSize(3);
tft.drawString(shown,294,86);
tft.setTextDatum(TL_DATUM);
}
void calculatorKey(const String &key){
if(key=="C"){calc="0";calcStored=0;calcOperation=0;calcFreshNumber=true;}
else if(key=="+/-"){if(calc!="0"){if(calc.charAt(0)=='-')calc.remove(0,1);else calc="-"+calc;}}
else if(key=="%") { calc=String(calc.toFloat()/100.0f,4); calcFreshNumber=true; }
else if(key=="+"||key=="-"||key=="x"||key=="/") { calcStored=calc.toFloat();calcOperation=key.charAt(0);calcFreshNumber=true; }
else if(key=="="){
float right=calc.toFloat(),answer=calcStored;
if(calcOperation=='+')answer+=right; else if(calcOperation=='-')answer-=right; else if(calcOperation=='x')answer*=right; else if(calcOperation=='/'){if(right==0){calc="Error";calcOperation=0;calcFreshNumber=true;drawCalcDisplay();return;}answer/=right;}
calc=String(answer,4);while(calc.indexOf('.')>=0&&calc.endsWith("0"))calc.remove(calc.length()-1);if(calc.endsWith("."))calc.remove(calc.length()-1);calcOperation=0;calcFreshNumber=true;
} else {
if(calcFreshNumber||calc=="0"){calc=key;calcFreshNumber=false;}else if(key=="."&&calc.indexOf('.')<0)calc+=key;else if(key!=".")calc+=key;
}
drawCalcDisplay();
}
void drawCalc(){
nav("Calculator");
drawCalcDisplay();
const char* keys[]={"C","+/-","%","/","7","8","9","x","4","5","6","-","1","2","3","+","0",".","=","="};
uint16_t keyColors[]={TFT_DARKGREY,TFT_DARKGREY,TFT_DARKGREY,TFT_ORANGE,TFT_DARKGREY,TFT_DARKGREY,TFT_DARKGREY,TFT_ORANGE,TFT_DARKGREY,TFT_DARKGREY,TFT_DARKGREY,TFT_ORANGE,TFT_DARKGREY,TFT_DARKGREY,TFT_DARKGREY,TFT_ORANGE,TFT_DARKGREY,TFT_DARKGREY,TFT_ORANGE,TFT_ORANGE};
for(int row=0;row<5;row++)for(int col=0;col<4;col++){int i=row*4+col;roundButton(15+col*76,151+row*57,62,47,keys[i],keyColors[i]);}
}
// Notes uses the same typing layout as Messenger: Shift, 123/ABC, Space,
// Back, and a right-side action key. These helpers let typing redraw only
// the white note area instead of flashing the whole page or keyboard.
void drawNoteInput(){
tft.fillRoundRect(12,66,296,104,12,TFT_WHITE);
label("My Note",25,77,TFT_ORANGE,TFT_WHITE,2);
String shown=noteText.substring(max(0,(int)noteText.length()-42));
label(shown,24,116,TFT_BLACK,TFT_WHITE,2);
}
void drawNoteKeyboard(){
tft.fillRect(0,326,320,154,TFT_LIGHTGREY);
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
for(int r=0;r<3;r++){
const char* line=noteNumbers?digits[r]:letters[r];
int n=strlen(line),gap=2,w=(320-4-(n-1)*gap)/n,x=(320-(n*(w+gap)-gap))/2,y=328+r*35;
for(int i=0;i<n;i++){
tft.fillRoundRect(x+i*(w+gap),y,w,33,4,TFT_WHITE);
tft.drawRoundRect(x+i*(w+gap),y,w,33,4,TFT_DARKGREY);
char key=line[i]; if(!noteNumbers&&!noteCaps)key=tolower((unsigned char)key);
label(String(key),x+i*(w+gap)+w/2-3,y+8,TFT_BLACK,TFT_WHITE,2);
}
}
roundButton(4,438,48,40,noteCaps?"SHIFT":"shift",TFT_BLACK);
roundButton(55,438,39,40,noteNumbers?"ABC":"123",TFT_BLACK);
roundButton(97,438,116,40,"SPACE",TFT_WHITE,TFT_BLACK);
roundButton(216,438,50,40,"BACK",TFT_BLACK);
roundButton(269,438,47,40,"SAVE",TFT_BLUE);
}
void drawNotes(){
nav("Notes");
drawNoteInput();
label("Your note is saved on the microSD card.",35,210,TFT_LIGHTGREY,TFT_BLACK);
drawNoteKeyboard();
}
void drawNotifications(){
nav("Notifications");
label("Notifications",22,72,TFT_WHITE,TFT_BLACK,3);
label(notificationCount ? "Latest alerts" : "No notifications yet",22,101,TFT_LIGHTGREY,TFT_BLACK);
uint16_t bg=tft.color565(34,34,38);
int shown=min(4,notificationCount);
for(int row=0;row<shown;row++){
int index=notificationCount-1-row, y=119+row*78;
tft.fillRoundRect(12,y,296,68,16,bg);
// A black circle with a white ! marks each alert.
tft.fillCircle(42, y+34, 17, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.drawString("!", 42, y+34);
tft.setTextDatum(TL_DATUM);
label("Messenger",72,y+12,TFT_WHITE,bg,2);
label(notificationTexts[index].substring(0,34),72,y+39,TFT_LIGHTGREY,bg);
uint32_t ageSeconds = (millis() - notificationReceivedAt[index]) / 1000;
String age = ageSeconds < 2 ? "now" : String(ageSeconds) + "s";
label(age,263,y+14,TFT_LIGHTGREY,bg);
}
if(notificationCount>4) label("Showing the latest 4 alerts",69,440,TFT_LIGHTGREY,TFT_BLACK);
lastNotificationDraw = millis() / 1000;
}
// Only the tiny relative-time fields change once per second. This avoids the
// visible flash caused by repainting the whole Notifications page every tick.
void refreshNotificationAges() {
if (page != NOTIFICATIONS) return;
const uint16_t bg = tft.color565(34,34,38);
int shown = min(4, notificationCount);
for (int row = 0; row < shown; row++) {
int index = notificationCount - 1 - row;
int y = 119 + row * 78;
tft.fillRect(258, y + 10, 42, 17, bg);
uint32_t ageSeconds = (millis() - notificationReceivedAt[index]) / 1000;
String age = ageSeconds < 2 ? "now" : String(ageSeconds) + "s";
label(age, 263, y + 14, TFT_LIGHTGREY, bg);
}
lastNotificationDraw = millis() / 1000;
}
void refreshClock(){
tft.fillRect(34,155,260,55,TFT_BLACK);
label(networkTimeText(true),45,170,TFT_CYAN,TFT_BLACK,4);
lastClockDraw=millis()/1000;
}
void drawClock(){
nav("Clock");
label("CLOCK",111,105,TFT_WHITE,TFT_BLACK,3);
refreshClock();
label(networkTimeReady?"Real time from Wi-Fi (Eastern Time)":"Waiting for Wi-Fi time",30,250,TFT_LIGHTGREY,TFT_BLACK);
}
String jsonValue(const String &s,const String &key){
int p=s.indexOf("\""+key+"\":");
if(p<0)return "";
p+=key.length()+3;
int e=s.indexOf(',',p);
if(e<0)e=s.indexOf('}',p);
String v=s.substring(p,e);
v.replace("\"","");
return v;
}
// Read a number only from one named JSON object. Open-Meteo also sends units
// and dates elsewhere in its reply, so a whole-reply search can select a date.
String jsonObjectValue(const String &json,const String &objectName,const String &key){
int objectAt=json.indexOf("\""+objectName+"\":{");
if(objectAt<0)return "";
int objectEnd=json.indexOf('}',objectAt);
if(objectEnd<0)return "";
String object=json.substring(objectAt,objectEnd+1);
return jsonValue(object,key);
}
String jsonFirstArrayValue(const String &json,const String &objectName,const String &key){
int objectAt=json.indexOf("\""+objectName+"\":{");
if(objectAt<0)return "";
int objectEnd=json.indexOf('}',objectAt);
if(objectEnd<0)return "";
int keyAt=json.indexOf("\""+key+"\":[",objectAt);
if(keyAt<0||keyAt>objectEnd)return "";
int start=keyAt+key.length()+4;
int end=json.indexOf(',',start);
if(end<0||end>objectEnd)end=json.indexOf(']',start);
if(end<0)return "";
return json.substring(start,end);
}
String weatherText(){
if(weatherCode < 0) return weatherNow;
if(weatherCode == 0) return "Clear sky";
if(weatherCode <= 3) return "Partly cloudy";
if(weatherCode <= 48) return "Foggy";
if(weatherCode <= 67) return "Rain showers";
if(weatherCode <= 77) return "Snow showers";
if(weatherCode <= 82) return "Heavy showers";
return "Thunderstorms";
}
void drawWeatherSymbol(int cx,int cy){
bool rainy=weatherCode>=51 && weatherCode<=82;
bool cloudy=weatherCode>=1 || weatherCode<0;
if(!rainy || cloudy){
tft.fillCircle(cx-13,cy-12,19,TFT_YELLOW);
for(int a=0;a<8;a++){float r=a*0.785f;tft.drawLine(cx-13+(int)(25*cos(r)),cy-12+(int)(25*sin(r)),cx-13+(int)(31*cos(r)),cy-12+(int)(31*sin(r)),TFT_YELLOW);}
}
if(cloudy){tft.fillCircle(cx-3,cy+4,15,TFT_WHITE);tft.fillCircle(cx+14,cy+5,20,TFT_WHITE);tft.fillCircle(cx+31,cy+10,12,TFT_WHITE);tft.fillRect(cx-4,cy+5,36,17,TFT_WHITE);}
if(rainy)for(int i=0;i<3;i++)tft.drawLine(cx+i*13,cy+30,cx-4+i*13,cy+40,TFT_CYAN);
}
void refreshWeather(){
if(WiFi.status()!=WL_CONNECTED){weatherNow="Connect to Wi-Fi first";weatherPlace="";weatherCode=-1;drawWeather();return;}
HTTPClient http;
// Weather is optional. Keep a failed hotspot/Internet lookup from freezing
// the screen for seven seconds after the user taps Refresh.
http.setConnectTimeout(900);
http.setTimeout(1800);
if(!http.begin("http://ip-api.com/json/?fields=status,city,lat,lon")){weatherNow="Location lookup failed";drawWeather();return;}
int status=http.GET();String place=status==200?http.getString():"";http.end();
String lat=jsonValue(place,"lat"),lon=jsonValue(place,"lon");weatherPlace=jsonValue(place,"city");
if(!lat.length()||!lon.length()){weatherNow="Location unavailable";drawWeather();return;}
String url="http://api.open-meteo.com/v1/forecast?latitude="+lat+"&longitude="+lon+"¤t=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=auto";
if(!http.begin(url)){weatherNow="Weather service unavailable";drawWeather();return;}
status=http.GET();String data=status==200?http.getString():"";http.end();
// Use the actual current and daily data objects, never the matching unit labels.
String temp=jsonObjectValue(data,"current","temperature_2m");
if(!temp.length()){weatherNow="No weather reply";drawWeather();return;}
weatherNow=temp+" F";
weatherCode=jsonObjectValue(data,"current","weather_code").toInt();
weatherHigh=jsonFirstArrayValue(data,"daily","temperature_2m_max");
weatherLow=jsonFirstArrayValue(data,"daily","temperature_2m_min");
if(!weatherHigh.length())weatherHigh="--";
if(!weatherLow.length())weatherLow="--";
drawWeather();
}
void drawWeather(){
nav("Weather");
const uint16_t navy=tft.color565(13,25,62), blue=tft.color565(35,91,204), cardBlue=tft.color565(55,111,220), panel=tft.color565(28,39,74);
// This complete scene is drawn only on entry or after the Refresh button is tapped.
tft.fillRect(0,56,320,424,navy);
tft.fillRoundRect(12,70,296,220,25,blue);
tft.fillRoundRect(12,70,296,76,25,cardBlue);
label(weatherPlace.length()?weatherPlace:"Local weather",27,87,TFT_WHITE,cardBlue,2);
label(weatherText(),27,116,TFT_WHITE,cardBlue);
String temp=weatherNow;int sp=temp.indexOf(" F");if(sp>0)temp=temp.substring(0,sp)+"°F";
// A temperature uses the large type only after a real weather reply arrives.
// The longer loading/error messages get their own two-line area on the left,
// while the illustration stays on the right, so neither can cover the other.
bool hasTemperature = weatherCode >= 0 && sp > 0;
if (hasTemperature) {
label(temp,25,155,TFT_WHITE,blue,4);
label("NOW",28,218,TFT_CYAN,blue);
} else {
String first = temp;
String second = "";
int cut = first.lastIndexOf(' ', 20);
if (cut > 0 && first.length() > 20) {
second = first.substring(cut + 1);
first = first.substring(0, cut);
}
label(first,25,160,TFT_WHITE,blue,2);
if (second.length()) label(second,25,187,TFT_WHITE,blue,2);
label("Tap the button below",25,228,TFT_CYAN,blue);
}
drawWeatherSymbol(246,164);
tft.fillRoundRect(12,303,142,75,17,panel);
tft.fillRoundRect(166,303,142,75,17,panel);
label("TODAY'S HIGH",26,318,TFT_LIGHTGREY,panel);
label(weatherHigh+"°",27,342,TFT_WHITE,panel,3);
label("TONIGHT'S LOW",180,318,TFT_LIGHTGREY,panel);
label(weatherLow+"°",181,342,TFT_WHITE,panel,3);
tft.fillRoundRect(12,391,296,42,14,panel);
label(WiFi.status()==WL_CONNECTED?"Uses your Wi-Fi location":"Connect Wi-Fi in Settings to update",25,406,WiFi.status()==WL_CONNECTED?TFT_CYAN:TFT_ORANGE,panel);
roundButton(42,443,236,30,"REFRESH WEATHER",TFT_BLUE);
}
void resetSudoku(bool nextMap=false){ if(nextMap) sudokuMap=(sudokuMap+1)%3; memcpy(sudokuPuzzle,sudokuPuzzles[sudokuMap],sizeof(sudokuPuzzle)); memcpy(sudokuAnswer,sudokuAnswers[sudokuMap],sizeof(sudokuAnswer)); memcpy(sudokuBoard,sudokuPuzzle,sizeof(sudokuBoard)); sudokuCell=-1; sudokuMistakes=0; sudokuStarted=false; }
void sendSudokuState(){
if(!gamePeer || !gamePeer.connected()) return;
gamePeer.printf("P,%d,%d,%d\\n",sudokuMap,sudokuMistakes,gameDone?1:0);
for(int i=0;i<81;i++) if(sudokuBoard[i]) gamePeer.printf("S,%d,%d\\n",i,sudokuBoard[i]);
if(gameDone && sudokuMistakes>=3) gamePeer.print("L\\n");
else if(gameDone) gamePeer.print("F\\n");
}
void resetGames(){resetSudoku();const char*c[]={"rnbqkbnr","pppppppp","........","........","........","........","PPPPPPPP","RNBQKBNR"};for(int y=0;y<8;y++)for(int x=0;x<8;x++){chess[y][x]=c[y][x];checkers[y][x]=((y<3||y>4)&&((x+y)&1))?(y<3?'r':'b'):'.';}pickedX=pickedY=-1;whiteTurn=redTurn=true;gameDone=false;result="";}
void chessSquare(int x,int y){
int s=38,ox=8,oy=112,cx=ox+x*s+19,cy=oy+y*s+19;
uint16_t b=(x+y)&1?TFT_DARKGREEN:TFT_LIGHTGREY;
tft.fillRect(ox+x*s,oy+y*s,s,s,b);
if(x==pickedX&&y==pickedY)tft.drawRect(ox+x*s+2,oy+y*s+2,s-4,s-4,TFT_YELLOW);
char p=chess[y][x]; if(p=='.')return;
uint16_t pc=isupper(p)?TFT_WHITE:TFT_BLACK;
uint16_t edge=isupper(p)?TFT_DARKGREY:TFT_LIGHTGREY;
char kind=tolower((unsigned char)p);
// Detailed, outlined silhouettes: the shapes match the familiar rook, knight,
// bishop, queen, king, and pawn appearance instead of letter-like markers.
tft.fillRoundRect(cx-14,cy+10,28,6,3,pc);
tft.drawRoundRect(cx-14,cy+10,28,6,3,edge);
if(kind=='p'){
tft.fillCircle(cx,cy-7,6,pc); tft.drawCircle(cx,cy-7,6,edge);
tft.fillTriangle(cx-8,cy+9,cx+8,cy+9,cx,cy-1,pc);
} else if(kind=='r'){
tft.fillRect(cx-10,cy-7,20,16,pc); tft.drawRect(cx-10,cy-7,20,16,edge);
tft.fillRect(cx-11,cy-11,5,6,pc); tft.fillRect(cx-2,cy-11,5,6,pc); tft.fillRect(cx+6,cy-11,5,6,pc);
tft.drawFastHLine(cx-10,cy-4,20,edge);
} else if(kind=='n'){
tft.fillTriangle(cx-10,cy+9,cx+10,cy+9,cx+6,cy-12,pc);
tft.fillCircle(cx+3,cy-6,6,pc); tft.drawPixel(cx+5,cy-8,edge);
tft.drawLine(cx-10,cy+9,cx+6,cy-12,edge); tft.drawLine(cx+6,cy-12,cx+10,cy+9,edge);
} else if(kind=='b'){
tft.fillCircle(cx,cy-9,6,pc); tft.drawLine(cx-3,cy-12,cx+3,cy-6,edge);
tft.fillTriangle(cx-9,cy+9,cx+9,cy+9,cx,cy-3,pc); tft.drawTriangle(cx-9,cy+9,cx+9,cy+9,cx,cy-3,edge);
} else if(kind=='q'){
tft.fillCircle(cx-8,cy-10,3,pc); tft.fillCircle(cx,cy-13,3,pc); tft.fillCircle(cx+8,cy-10,3,pc);
tft.fillTriangle(cx-11,cy+9,cx+11,cy+9,cx,cy-5,pc); tft.drawTriangle(cx-11,cy+9,cx+11,cy+9,cx,cy-5,edge);
tft.drawFastHLine(cx-9,cy+5,18,edge);
} else {
tft.fillRect(cx-2,cy-14,4,13,pc); tft.fillRect(cx-7,cy-10,14,4,pc);
tft.fillTriangle(cx-10,cy+9,cx+10,cy+9,cx,cy-3,pc); tft.drawTriangle(cx-10,cy+9,cx+10,cy+9,cx,cy-3,edge);
tft.drawFastHLine(cx-7,cy-10,14,edge);
}
}
void checkerSquare(int x,int y){int s=38,ox=8,oy=112;uint16_t b=(x+y)&1?TFT_DARKGREY:TFT_LIGHTGREY;tft.fillRect(ox+x*s,oy+y*s,s,s,b);if(x==pickedX&&y==pickedY)tft.drawRect(ox+x*s+2,oy+y*s+2,s-4,s-4,TFT_YELLOW);char p=checkers[y][x];if(p!='.'){int cx=ox+x*s+19,cy=oy+y*s+19;uint16_t z=p=='r'?TFT_RED:TFT_BLUE;tft.fillCircle(cx,cy+2,14,TFT_BLACK);tft.fillCircle(cx,cy,13,z);tft.drawCircle(cx,cy,10,p=='r'?TFT_YELLOW:TFT_CYAN);}}
void gameTop(const String&s){
nav(s);
roundButton(78,61,76,34,"2 PLAY",gameMode==TWO_PLAYER_MODE?TFT_BLUE:TFT_DARKGREY);
roundButton(160,61,74,34,"AI",gameMode==AI_OFF_MODE?TFT_BLUE:TFT_DARKGREY);
roundButton(240,61,72,34,"WI-FI",gameMode==WIFI_MODE?TFT_BLUE:TFT_DARKGREY);
roundButton(10,426,300,42,"NEW GAME",TFT_GREEN);
}
void checkersStatus(){
tft.fillRect(0,96,320,16,TFT_BLACK);
label(gameDone?result:(redTurn?"Red's turn":"Blue's turn"),10,99,gameDone?TFT_YELLOW:TFT_WHITE,TFT_BLACK);
}
void drawCheckers(bool full){
if(full){
gameTop("Checkers");
for(int y=0;y<8;y++)for(int x=0;x<8;x++)checkerSquare(x,y);
}
checkersStatus();
}
void drawSudoku(bool full){
if(full){
nav("Sudoku");
label("Local pass-and-play puzzle",55,64,TFT_CYAN,TFT_BLACK,2);
if(gameDone && sudokuMistakes >= 3) label("Round over - start a new puzzle",38,92,TFT_RED,TFT_BLACK,2);
else label(String("Mistakes: ")+sudokuMistakes+" / 3",96,92,sudokuMistakes==0?TFT_WHITE:TFT_ORANGE,TFT_BLACK,2);
const int ox=16,oy=112,s=32;
for(int r=0;r<9;r++)for(int c=0;c<9;c++){
int i=r*9+c; uint16_t bg=sudokuPuzzle[i]?tft.color565(48,48,55):tft.color565(24,24,29);
if(i==sudokuCell) bg=tft.color565(45,84,145);
tft.fillRect(ox+c*s,oy+r*s,s,s,bg);
tft.drawRect(ox+c*s,oy+r*s,s,s,tft.color565(90,90,98));
if(c%3==0)tft.drawFastVLine(ox+c*s,oy,288,TFT_WHITE);
if(r%3==0)tft.drawFastHLine(ox,oy+r*s,288,TFT_WHITE);
if(sudokuBoard[i]) label(String(sudokuBoard[i]),ox+c*s+11,oy+r*s+8,sudokuPuzzle[i]?TFT_WHITE:TFT_CYAN,bg,2);
}
tft.drawRect(ox,oy,288,288,TFT_WHITE);
for(int n=1;n<=9;n++) roundButton(8+(n-1)*34,404,30,30,String(n),TFT_DARKGREY);
roundButton(70,446,180,28,"NEW PUZZLE",TFT_BLUE);
}
}
void sudokuTap(int x,int y){
// This is a local puzzle: it does not host, join, or use Wi-Fi.
if(gameDone){
if(y>=446&&y<480){resetSudoku(true);gameDone=false;result="";drawSudoku();}
return;
}
if(y>=112&&y<400&&x>=16&&x<304){int c=(x-16)/32,r=(y-112)/32,i=r*9+c;if(!sudokuPuzzle[i]){sudokuCell=i;drawSudoku();}return;}
if(y>=404&&y<438&&sudokuCell>=0){int n=(x-8)/34+1;if(n<1||n>9)return;if(sudokuAnswer[sudokuCell]!=n){sudokuMistakes++; sudokuCell=-1; if(sudokuMistakes>=3){gameDone=true;result="3 mistakes - puzzle locked";addNotification("Sudoku locked after 3 mistakes");} drawSudoku();return;} sudokuBoard[sudokuCell]=n; sudokuCell=-1; bool complete=true;for(int i=0;i<81;i++)if(!sudokuBoard[i])complete=false;if(complete){gameDone=true;result="Puzzle complete!";addNotification("Sudoku puzzle complete!");}drawSudoku();return;}
if(y>=446&&y<480){resetSudoku(true);gameDone=false;result="";drawSudoku();}
}
void drawGameWifi(){tft.fillScreen(TFT_BLACK);nav(gameType==CHESS_GAME?"Chess Wi-Fi":gameType==CHECKERS_GAME?"Checkers Wi-Fi":"Sudoku Wi-Fi");label(gameType==SUDOKU_GAME?"Race to solve the same puzzle":"Play on two ESP32 screens",44,107,TFT_WHITE,TFT_BLACK,2);roundButton(22,145,276,57,"HOST GAME",TFT_BLUE);roundButton(22,220,276,57,"JOIN SAVED IP",TFT_GREEN);roundButton(22,295,276,50,"OPEN MESSENGER SETUP",TFT_DARKGREY);label("IP: "+savedIp,64,375,TFT_LIGHTGREY,TFT_BLACK);}
void sendMove(int fx,int fy,int tx,int ty){if(gamePeer&&gamePeer.connected())gamePeer.printf("M,%d,%d,%d,%d\n",fx,fy,tx,ty);}
bool inside(int x,int y){return x>=0&&x<8&&y>=0&&y<8;}
bool sameChessSide(char a,char b){return a!='.'&&b!='.'&&(isupper((unsigned char)a)==isupper((unsigned char)b));}
bool chessClear(int fx,int fy,int tx,int ty){int dx=(tx>fx)-(tx<fx),dy=(ty>fy)-(ty<fy);for(fx+=dx,fy+=dy;fx!=tx||fy!=ty;fx+=dx,fy+=dy)if(chess[fy][fx]!='.')return false;return true;}
bool chessAttacked(int tx,int ty,bool byWhite){
int pawnY=ty+(byWhite?1:-1); if(inside(tx-1,pawnY)&&chess[pawnY][tx-1]==(byWhite?'P':'p'))return true; if(inside(tx+1,pawnY)&&chess[pawnY][tx+1]==(byWhite?'P':'p'))return true;
const int nx[]={1,2,2,1,-1,-2,-2,-1},ny[]={-2,-1,1,2,2,1,-1,-2}; for(int i=0;i<8;i++)if(inside(tx+nx[i],ty+ny[i])&&chess[ty+ny[i]][tx+nx[i]]==(byWhite?'N':'n'))return true;
for(int d=0;d<8;d++){int dx=(d==0||d==1||d==7)?1:(d==3||d==4||d==5)?-1:0,dy=(d==1||d==2||d==3)?1:(d==5||d==6||d==7)?-1:0;for(int x=tx+dx,y=ty+dy;inside(x,y);x+=dx,y+=dy){char p=chess[y][x];if(p=='.')continue;if(isupper((unsigned char)p)==byWhite){char k=tolower((unsigned char)p);if((d%2==0&&(k=='r'||k=='q'))||(d%2==1&&(k=='b'||k=='q'))||(abs(x-tx)<=1&&abs(y-ty)<=1&&k=='k'))return true;}break;}}
return false;
}
bool chessInCheck(bool white){for(int y=0;y<8;y++)for(int x=0;x<8;x++)if(chess[y][x]==(white?'K':'k'))return chessAttacked(x,y,!white);return true;}
bool chessShapeOK(int fx,int fy,int tx,int ty){
if(!inside(fx,fy)||!inside(tx,ty)||(fx==tx&&fy==ty))return false;char p=chess[fy][fx],q=chess[ty][tx];if(p=='.'||sameChessSide(p,q)||tolower((unsigned char)q)=='k')return false;int dx=tx-fx,dy=ty-fy,ax=abs(dx),ay=abs(dy);char k=tolower((unsigned char)p);
if(k=='p'){int dir=isupper((unsigned char)p)?-1:1,start=isupper((unsigned char)p)?6:1;if(dx==0&&dy==dir&&q=='.')return true;if(dx==0&&dy==2*dir&&fy==start&&q=='.'&&chess[fy+dir][fx]=='.')return true;return ax==1&&dy==dir&&q!='.';}
if(k=='n')return (ax==1&&ay==2)||(ax==2&&ay==1); if(k=='b')return ax==ay&&chessClear(fx,fy,tx,ty); if(k=='r')return (ax==0||ay==0)&&chessClear(fx,fy,tx,ty); if(k=='q')return (ax==ay||ax==0||ay==0)&&chessClear(fx,fy,tx,ty); if(k=='k')return ax<=1&&ay<=1; return false;
}
bool chessLegal(int fx,int fy,int tx,int ty){if(!chessShapeOK(fx,fy,tx,ty))return false;char save[8][8];memcpy(save,chess,sizeof(chess));char p=chess[fy][fx];chess[ty][tx]=p;chess[fy][fx]='.';bool ok=!chessInCheck(isupper((unsigned char)p));memcpy(chess,save,sizeof(chess));return ok;}
bool chessHasMove(bool white){for(int y=0;y<8;y++)for(int x=0;x<8;x++)if(chess[y][x]!='.'&&(isupper((unsigned char)chess[y][x])==white))for(int ty=0;ty<8;ty++)for(int tx=0;tx<8;tx++)if(chessLegal(x,y,tx,ty))return true;return false;}
void chessStatus(){tft.fillRect(0,96,320,16,TFT_BLACK);label(gameDone?result:(whiteTurn?"White's turn":"Black's turn"),10,99,gameDone?TFT_YELLOW:TFT_WHITE,TFT_BLACK);}
void drawChess(bool full){if(full){gameTop("Chess");for(int y=0;y<8;y++)for(int x=0;x<8;x++)chessSquare(x,y);}chessStatus();}
void playChessMove(int fx,int fy,int tx,int ty,bool transmit){
char moving=chess[fy][fx];
chess[ty][tx]=moving;
chess[fy][fx]='.';
pickedX=pickedY=-1;
whiteTurn=!whiteTurn;
if(chessInCheck(whiteTurn)){
if(!chessHasMove(whiteTurn)){gameDone=true;result=whiteTurn?"Checkmate - Black wins!":"Checkmate - White wins!";}
else result=whiteTurn?"White is in check":"Black is in check";
}else if(!chessHasMove(whiteTurn)){gameDone=true;result="Stalemate";}
else result="";
// A move changes only these two squares and the small status strip.
chessSquare(fx,fy);
chessSquare(tx,ty);
chessStatus();
if(transmit)sendMove(fx,fy,tx,ty);
}
void chessTap(int x,int y){
// In AI mode the person is White, so Black is reserved for the computer.
if(gameDone||y<112||y>=416||(gameMode==AI_OFF_MODE&&!whiteTurn))return;
int bx=(x-8)/38,by=(y-112)/38;if(!inside(bx,by))return;
if(pickedX<0){char p=chess[by][bx];if(p!='.'&&isupper((unsigned char)p)==whiteTurn){pickedX=bx;pickedY=by;chessSquare(bx,by);}return;}
if(chessLegal(pickedX,pickedY,bx,by)){playChessMove(pickedX,pickedY,bx,by,true);return;}
char p=chess[by][bx];int oldX=pickedX,oldY=pickedY;pickedX=pickedY=-1;chessSquare(oldX,oldY);
if(p!='.'&&isupper((unsigned char)p)==whiteTurn){pickedX=bx;pickedY=by;chessSquare(bx,by);}
}
bool checkerRed(char p){return p=='r'||p=='R';} bool checkerKing(char p){return p=='R'||p=='B';}
bool checkerCaptureAt(int fx,int fy){char p=checkers[fy][fx];if(p=='.')return false;for(int dy=-1;dy<=1;dy+=2){if(!checkerKing(p)&&dy!=(checkerRed(p)?1:-1))continue;for(int dx=-1;dx<=1;dx+=2){int mx=fx+dx,my=fy+dy,tx=fx+2*dx,ty=fy+2*dy;if(inside(tx,ty)&&checkers[ty][tx]=='.'&&checkers[my][mx]!='.'&&checkerRed(checkers[my][mx])!=checkerRed(p))return true;}}return false;}
bool checkersAnyCapture(bool red){for(int y=0;y<8;y++)for(int x=0;x<8;x++)if(checkers[y][x]!='.'&&checkerRed(checkers[y][x])==red&&checkerCaptureAt(x,y))return true;return false;}
bool checkerMoveOK(int fx,int fy,int tx,int ty){if(!inside(fx,fy)||!inside(tx,ty)||checkers[ty][tx]!='.')return false;char p=checkers[fy][fx];if(p=='.')return false;int dx=abs(tx-fx),dy=ty-fy,ady=abs(dy);if(dx!=ady||(dx!=1&&dx!=2))return false;if(!checkerKing(p)&&dy!=(checkerRed(p)?dx:-dx))return false;if(dx==2){char mid=checkers[(fy+ty)/2][(fx+tx)/2];return mid!='.'&&checkerRed(mid)!=checkerRed(p);}return !checkersAnyCapture(checkerRed(p));}
bool checkerHasMove(bool red){for(int y=0;y<8;y++)for(int x=0;x<8;x++)if(checkers[y][x]!='.'&&checkerRed(checkers[y][x])==red)for(int ty=0;ty<8;ty++)for(int tx=0;tx<8;tx++)if(checkerMoveOK(x,y,tx,ty))return true;return false;}
void countCheckers(){int r=0,b=0;for(int y=0;y<8;y++)for(int x=0;x<8;x++){if(checkers[y][x]!='.'&&checkerRed(checkers[y][x]))r++;if(checkers[y][x]!='.'&&!checkerRed(checkers[y][x]))b++;}if(!r){gameDone=true;result="Blue wins!";}else if(!b){gameDone=true;result="Red wins!";}else if(!checkerHasMove(redTurn)){gameDone=true;result=redTurn?"Blue wins - Red is stuck":"Red wins - Blue is stuck";}}
void playCheckerMove(int fx,int fy,int tx,int ty,bool transmit){
char p=checkers[fy][fx];
bool jump=abs(tx-fx)==2;
int capturedX=(fx+tx)/2, capturedY=(fy+ty)/2;
checkers[ty][tx]=p;
checkers[fy][fx]='.';
if(jump)checkers[capturedY][capturedX]='.';
if((checkerRed(p)&&ty==7)||(!checkerRed(p)&&ty==0))checkers[ty][tx]=checkerRed(p)?'R':'B';
pickedX=pickedY=-1;
countCheckers();
if(!gameDone&&jump&&checkerCaptureAt(tx,ty)){
pickedX=tx;pickedY=ty;
result=redTurn?"Red must jump again":"Blue must jump again";
}else if(!gameDone){redTurn=!redTurn;result="";countCheckers();}
// Redraw the moved counter, its empty old square, and a captured counter only.
checkerSquare(fx,fy);
checkerSquare(tx,ty);
if(jump)checkerSquare(capturedX,capturedY);
if(pickedX>=0)checkerSquare(pickedX,pickedY);
checkersStatus();
if(transmit)sendMove(fx,fy,tx,ty);
}
void checkerTap(int x,int y){
// In AI mode the person is Red, so Blue is reserved for the computer.
if(gameDone||y<112||y>=416||(gameMode==AI_OFF_MODE&&!redTurn))return;
int bx=(x-8)/38,by=(y-112)/38;if(!inside(bx,by))return;
if(pickedX<0){char p=checkers[by][bx];if(p!='.'&&checkerRed(p)==redTurn&&(!checkersAnyCapture(redTurn)||checkerCaptureAt(bx,by))){pickedX=bx;pickedY=by;checkerSquare(bx,by);}return;}
if(checkerMoveOK(pickedX,pickedY,bx,by)){playCheckerMove(pickedX,pickedY,bx,by,true);return;}
if(pickedX==bx&&pickedY==by&&result.length()==0){checkerSquare(pickedX,pickedY);pickedX=pickedY=-1;}
}
// A simple on-device opponent: it takes a legal capture first, then the first legal move.
// Chess uses Black; Checkers uses Blue. It runs after a short pause so the player's move is visible.
void doChessAiMove(){
int bestFx=-1,bestFy=-1,bestTx=-1,bestTy=-1,bestScore=-1;
for(int fy=0;fy<8;fy++)for(int fx=0;fx<8;fx++) if(chess[fy][fx]!='.'&&!isupper((unsigned char)chess[fy][fx]))
for(int ty=0;ty<8;ty++)for(int tx=0;tx<8;tx++) if(chessLegal(fx,fy,tx,ty)){
char taken=chess[ty][tx]; int score=taken=='.'?0:1;
if(tolower((unsigned char)taken)=='q')score=5;
else if(tolower((unsigned char)taken)=='r')score=4;
else if(tolower((unsigned char)taken)=='b'||tolower((unsigned char)taken)=='n')score=3;
if(score>bestScore){bestScore=score;bestFx=fx;bestFy=fy;bestTx=tx;bestTy=ty;}
}
if(bestFx>=0)playChessMove(bestFx,bestFy,bestTx,bestTy,false);
}
void doCheckersAiMove(){
int bestFx=-1,bestFy=-1,bestTx=-1,bestTy=-1; bool mustJump=checkersAnyCapture(false);
for(int fy=0;fy<8&&bestFx<0;fy++)for(int fx=0;fx<8&&bestFx<0;fx++) if(checkers[fy][fx]!='.'&&!checkerRed(checkers[fy][fx]))
for(int ty=0;ty<8&&bestFx<0;ty++)for(int tx=0;tx<8;tx++) if(checkerMoveOK(fx,fy,tx,ty)&&(!mustJump||abs(tx-fx)==2)){
bestFx=fx;bestFy=fy;bestTx=tx;bestTy=ty;
}
if(bestFx>=0)playCheckerMove(bestFx,bestFy,bestTx,bestTy,false);
}
void doAiMove(){
if(gameDone||gameMode!=AI_OFF_MODE)return;
if(gameType==CHESS_GAME&&!whiteTurn)doChessAiMove();
if(gameType==CHECKERS_GAME&&!redTurn)doCheckersAiMove();
}
void receiveMove(){
if(!gamePeer||!gamePeer.available())return;
// A partial Wi-Fi packet must not make touch wait for the normal stream timeout.
gamePeer.setTimeout(8);
String m=gamePeer.readStringUntil('\n');
m.trim();
if(gameType==SUDOKU_GAME){int cell,value,map,mistakes,finished;
if(sscanf(m.c_str(),"S,%d,%d",&cell,&value)==2&&cell>=0&&cell<81&&sudokuAnswer[cell]==value){sudokuBoard[cell]=value;if(page==SUDOKU)drawSudoku();}
else if(sscanf(m.c_str(),"P,%d,%d,%d",&map,&mistakes,&finished)==3){sudokuMap=(map%3+3)%3;resetSudoku();sudokuMistakes=mistakes;gameDone=finished!=0;result=gameDone?(sudokuMistakes>=3?"You win - other player made 3 mistakes!":"Other player finished first"):"";if(page==SUDOKU)drawSudoku();}
else if(m=="F"){gameDone=true;result="Other player finished first";if(page==SUDOKU)drawSudoku();addNotification("Other player won Sudoku");}
else if(m=="L"){gameDone=true;result="You win - other player made 3 mistakes!";if(page==SUDOKU)drawSudoku();addNotification("You won Wi-Fi Sudoku!");}
else if(m.startsWith("N,")){sudokuMap=(m.substring(2).toInt()%3+3)%3;resetSudoku();gameDone=false;result="";if(page==SUDOKU)drawSudoku();}
return;}int fx,fy,tx,ty;if(sscanf(m.c_str(),"M,%d,%d,%d,%d",&fx,&fy,&tx,&ty)!=4||!inside(fx,fy)||!inside(tx,ty))return;if(gameType==CHESS_GAME){if(chessLegal(fx,fy,tx,ty))playChessMove(fx,fy,tx,ty,false);}else if(checkerMoveOK(fx,fy,tx,ty))playCheckerMove(fx,fy,tx,ty,false);}
void open(Page p){page=p; if(p==SETTINGS)drawSettings();else if(p==BROWSER)drawBrowser();else if(p==STORE)drawStore();else if(p==PHOTOS)drawPhotos();else if(p==PIXEL)drawPixelStudio();else if(p==FILES)drawFiles();else if(p==SCREEN_TIME)drawScreenTime();else if(p==DEVICE)drawDevice();else if(p==CALC)drawCalc();else if(p==NOTES)drawNotes();else if(p==NOTIFICATIONS)drawNotifications();else if(p==CLOCKAPP)drawClock();else if(p==WEATHER)drawWeather();else if(p==MESSENGER)Messenger::draw();else if(p==CHESS){gameType=CHESS_GAME;drawChess();}else if(p==CHECKERS){gameType=CHECKERS_GAME;drawCheckers();}else if(p==SUDOKU){gameType=SUDOKU_GAME;drawSudoku();}}
void tap(int x,int y){if(page==MESSENGER){Messenger::tap(x,y);return;}if(page!=HOME&&x<72&&y<55){pixelNaming=false;drawHome();return;}if(page==HOME){
if(x>=270&&y>=30&&y<70){unreadNotificationCount=0;open(NOTIFICATIONS);return;}
if(y>=370&&y<397){homeScreen=x<160?0:1;drawHome();return;}
if(y>=397){if(x<86)open(MESSENGER);else if(x<162)open(BROWSER);else if(x<238)open(NOTES);else open(SETTINGS);return;}
if(x<12||x>315||y<75||y>365)return;
int col=(x-17)/77,row=(y-78)/103;if(col<0||col>3||row<0||row>2)return;
int n=homeScreen*12+row*4+col;if(n>=16)return;
Page map[]={MESSENGER,BROWSER,STORE,SETTINGS,CHESS,CHECKERS,SUDOKU,CALC,NOTES,PIXEL,FILES,CLOCKAPP,WEATHER,SCREEN_TIME,FILES,DEVICE};open(map[n]);return;}if(page==SETTINGS){ if(y>=190&&y<230){Messenger::openWifiSetup();return;}
else if(y>=325&&y<365)open(FILES);
else if(y>=370&&y<410)open(SCREEN_TIME);
else if(y>=415&&y<470)open(DEVICE);
return; } if(page==BROWSER){
if(y>=438&&y<475){
if(x<52){aiCaps=!aiCaps;drawBrowser();return;}
if(x<94){aiNumbers=!aiNumbers;drawBrowser();return;}
if(x<213){aiPrompt+=' ';drawAiPrompt();return;}
if(x<266){if(aiPrompt.length())aiPrompt.remove(aiPrompt.length()-1);drawAiPrompt();return;}
aiReply=makeAiReply(aiPrompt);
aiPrompt=""; // Start each new question with a clean input box.
drawAiPrompt();
drawAiReply();
return;
}
if(y>=326&&y<436){
int row=(y-326)/37;
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
const char* line=aiNumbers?digits[row]:letters[row];
int n=strlen(line),gap=1,w=(320-(n-1)*gap)/n,start=(320-(n*(w+gap)-gap))/2,i=(x-start)/(w+gap);
if(i>=0&&i<n&&x>=start+i*(w+gap)&&x<start+i*(w+gap)+w){char key=line[i];if(!aiNumbers&&!aiCaps)key=tolower((unsigned char)key);aiPrompt+=key;drawAiPrompt();}
return;
}
if(y>=67&&y<109&&x>=252){
aiReply=makeAiReply(aiPrompt);
aiPrompt=""; // Clear the completed question so typing can begin immediately.
drawAiPrompt();
drawAiReply();
}
return;
}
if(page==NOTIFICATIONS){
// Every visible alert comes from Messenger or a phone action. Tapping one
// takes the user straight to Messenger so a new text from the other screen
// can be read and answered immediately.
if(y>=119&&y<431){open(MESSENGER);}
return;
}
if(page==PHOTOS){if(selectedPhoto>=0){if(y>=370&&y<410){selectedPhoto=-1;drawPhotos();}else if(y>=420&&y<458){String path=String("/pixel_art_")+selectedPhoto+".bin";SD.remove(path.c_str());SD.remove((String("/pixel_art_")+selectedPhoto+".txt").c_str());selectedPhoto=-1;addNotification("Pixel art deleted");drawPhotos();}return;}if(y>=405&&y<445){open(PIXEL);return;}if(y>=100&&y<388){int col=x>=165?1:0,row=(y-100)/96,slot=row*2+col;if(slot>=0&&slot<6){uint16_t pixels[1024];uint8_t size=0;if(readPhoto(slot,pixels,size)){selectedPhoto=slot;drawPhotos();}}}return;}
if(pixelNaming){
// The naming page deliberately uses the same touch zones as Messenger.
// Only the small white name field changes while letters are typed.
if(y>=438&&y<475){
if(x<52){pixelNameCaps=!pixelNameCaps;drawPixelName();return;}
if(x<94){pixelNameNumbers=!pixelNameNumbers;drawPixelName();return;}
if(x<213){pixelName+=' ';}
else if(x<266){if(pixelName.length())pixelName.remove(pixelName.length()-1);}
else{
if(pixelRenameExisting) savePixelName(fileGallerySelected);
else savePixelArt();
pixelNaming=false; pixelRenameExisting=false; pixelNameCaps=false; pixelNameNumbers=false; open(FILES); return;
}
tft.fillRoundRect(12,66,296,52,12,TFT_WHITE);
String shown=pixelName.substring(max(0,(int)pixelName.length()-28));
label(shown,23,84,TFT_BLACK,TFT_WHITE,2);
return;
}
if(y>=328&&y<436){
int row=(y-328)/35;
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
const char* line=pixelNameNumbers?digits[row]:letters[row];
int n=strlen(line),gap=1,w=(320-(n-1)*gap)/n,start=(320-(n*(w+gap)-gap))/2,i=(x-start)/(w+gap);
if(i>=0&&i<n&&x>=start+i*(w+gap)&&x<start+i*(w+gap)+w){
char key=line[i];if(!pixelNameNumbers&&!pixelNameCaps)key=tolower((unsigned char)key);
pixelName+=key;
tft.fillRoundRect(12,66,296,52,12,TFT_WHITE);
String shown=pixelName.substring(max(0,(int)pixelName.length()-28));
label(shown,23,84,TFT_BLACK,TFT_WHITE,2);
}
}
return;
}
if(page==PIXEL){
if(pixelPalette){
if(y>=92&&y<352){int col=x/20,row=(y-92)/26,i=row*16+col;if(col>=0&&col<16&&row>=0&&row<10){pixelSelected=pixelColor(i);pixelErase=false;pixelEye=false;pixelPalette=false;drawPixelStudio();}return;}
if(y>=410&&y<452){pixelPalette=false;drawPixelStudio();} return;
}
if(y>=65&&y<369&&x>=8&&x<312){
int px=(x-8)/19,py=(y-65)/19;
if(px>=0&&px<16&&py>=0&&py<16){
// The first square starts one undoable stroke; held-finger movement adds
// nearby squares without redrawing the toolbar or the rest of the page.
pixelStrokeTo(px,py);
}
return;
}
if(y>=377&&y<425){
pixelStrokeActive=false;
if(x>=48&&x<94){
if(pixelUndoCount){
// Undo swaps the visible art into Redo, then restores the most recent
// saved before-stroke picture. The history is kept even for a one-pixel tap.
if(pixelRedoCount >= 7){memmove(pixelRedo[0],pixelRedo[1],sizeof(pixelRedo[0])*6);pixelRedoCount=6;}
pixelSnapshot(pixelRedo[pixelRedoCount++]);
pixelUndoCount--;
pixelRestoreCanvas(pixelUndo[pixelUndoCount]);
drawPixelUndoControl();drawPixelRedoControl();
}
}
else if(x>=94&&x<140){
if(pixelRedoCount){
// Redo performs the opposite swap, so Undo remains available afterwards.
if(pixelUndoCount >= 7){memmove(pixelUndo[0],pixelUndo[1],sizeof(pixelUndo[0])*6);pixelUndoCount=6;}
pixelSnapshot(pixelUndo[pixelUndoCount++]);
pixelRedoCount--;
pixelRestoreCanvas(pixelRedo[pixelRedoCount]);
drawPixelUndoControl();drawPixelRedoControl();
}
}
else if(x>=140&&x<181){pixelEye=!pixelEye;drawPixelPickControl();}
else if(x>=181&&x<231){pixelErase=!pixelErase;drawPixelEraseControl();}
else if(x>=231&&x<272){pixelPushUndo();pixelFlood(0,0,pixelErase?TFT_BLACK:pixelSelected);drawPixelCanvas();drawPixelUndoControl();drawPixelRedoControl();}
else if(x>=272){pixelPalette=true;drawPixelStudio();return;}
return;
}
if(y>=434&&y<475){
if(x<80){pixelNaming=true;pixelRenameExisting=false;pixelNameCaps=false;pixelNameNumbers=false;drawPixelName();}
else if(x<166){fileGallerySelected=-1;open(FILES);}
else if(x<224){memset(pixelCanvas,0,sizeof(pixelCanvas));memset(pixelUndo,0,sizeof(pixelUndo));memset(pixelRedo,0,sizeof(pixelRedo));pixelUndoCount=0;pixelRedoCount=0;pixelName="New picture";drawPixelStudio();}
return;
}
return;
}
if(page==FILES){
if(fileGallerySelected>=0){
if(y>=370&&y<408){
if(x<106){fileGallerySelected=-1;drawFiles();}
else if(x<204){
String path=String("/pixel_art_")+fileGallerySelected+".txt";
File n=SD.open(path.c_str(),FILE_READ);
pixelName=n?n.readString():String("Pixel art"); if(n)n.close(); pixelName.trim();
pixelNaming=true;pixelRenameExisting=true;pixelNameCaps=false;pixelNameNumbers=false;drawPixelName();
} else {SD.remove((String("/pixel_art_")+fileGallerySelected+".bin").c_str());SD.remove((String("/pixel_art_")+fileGallerySelected+".txt").c_str());addNotification("Pixel art deleted");fileGallerySelected=-1;drawFiles();}
return;
}
if(y>=423&&y<461){memset(pixelCanvas,0,sizeof(pixelCanvas));memset(pixelUndo,0,sizeof(pixelUndo));memset(pixelRedo,0,sizeof(pixelRedo));pixelName="New picture";open(PIXEL);return;}
return;
}
if(y>=392&&y<426){
if(x<74&&fileGalleryPage>0){fileGalleryPage--;drawFiles();}
else if(x>=82&&x<238){memset(pixelCanvas,0,sizeof(pixelCanvas));memset(pixelUndo,0,sizeof(pixelUndo));memset(pixelRedo,0,sizeof(pixelRedo));pixelName="New picture";open(PIXEL);}
else if(x>=246&&fileGalleryPage<((PIXEL_ART_SLOTS-1)/6)){fileGalleryPage++;drawFiles();}
return;
}
if(y>=110&&y<356){int col=x>=165?1:0,row=(y-110)/91,slot=fileGalleryPage*6+row*2+col;uint16_t pixels[1024];uint8_t size=0;if(slot<PIXEL_ART_SLOTS&&readPhoto(slot,pixels,size)){fileGallerySelected=slot;drawFiles();}return;}
return;
}
if(page==WEATHER){if(y>=443&&y<478){refreshWeather();}return;}
if(page==STORE){if(y>=99&&y<387&&x>=220){int choice=(y-99)/72;if(storeNotice==choice){Page appPage[]={MESSENGER,CHESS,CHECKERS,NOTES};open(appPage[choice]);}else{storeNotice=choice;drawStore();}}return;}
if(page==SUDOKU){sudokuTap(x,y);return;}
if(page==CHESS||page==CHECKERS){
if(y>=426&&y<468){
// Start the selected game over while keeping the currently selected mode.
resetGames();
if(page==CHESS)drawChess();else drawCheckers();
return;
}
if(y>=61&&y<95){
if(x>=78&&x<156){gameMode=TWO_PLAYER_MODE;if(page==CHESS)drawChess();else drawCheckers();}
else if(x>=160&&x<236){gameMode=AI_OFF_MODE;aiMoveAt=millis();if(page==CHESS)drawChess();else drawCheckers();}
else if(x>=240){gameMode=WIFI_MODE;page=GAME_WIFI;drawGameWifi();}
return;
}
if(page==CHESS)chessTap(x,y);else checkerTap(x,y);return;
}if(page==GAME_WIFI){if(y>=145&&y<202){gameMode=WIFI_MODE;hosting=true;joining=false;gameServer.begin();page=gameType==CHESS_GAME?CHESS:(gameType==CHECKERS_GAME?CHECKERS:SUDOKU);if(page==CHESS)drawChess();else if(page==CHECKERS)drawCheckers();else drawSudoku();}else if(y>=220&&y<277){gameMode=WIFI_MODE;hosting=false;joining=true;retryAt=0;page=gameType==CHESS_GAME?CHESS:(gameType==CHECKERS_GAME?CHECKERS:SUDOKU);if(page==CHESS)drawChess();else if(page==CHECKERS)drawCheckers();else drawSudoku();}else if(y>=295&&y<345)open(MESSENGER);return; }if(page==CALC){
if(y>=151&&y<436&&x>=15&&x<319){
int row=(y-151)/57,col=(x-15)/76;
if(row>=0&&row<5&&col>=0&&col<4){const char* keys[]={"C","+/-","%","/","7","8","9","x","4","5","6","-","1","2","3","+","0",".","=","="};calculatorKey(keys[row*4+col]);}
}
return;
}
if(page==NOTES){
if(y>=438&&y<475){
if(x<52){noteCaps=!noteCaps;drawNoteKeyboard();return;}
if(x<94){noteNumbers=!noteNumbers;drawNoteKeyboard();return;}
if(x<213){noteText+=' ';drawNoteInput();return;}
if(x<266){if(noteText.length())noteText.remove(noteText.length()-1);drawNoteInput();return;}
if(sdCardReady){
SD.remove("/notes.txt");
File f=SD.open("/notes.txt",FILE_WRITE);
if(f){f.print(noteText);f.close();addNotification("Note saved to microSD");}
else addNotification("Could not save note to microSD");
} else addNotification("Insert microSD to save notes");
return;
}
if(y>=328&&y<436){
int row=(y-328)/35;
const char* letters[]={"QWERTYUIOP","ASDFGHJKL","ZXCVBNM"};
const char* digits[]={"1234567890",".-/:;()@#","$&?!'\"+=_"};
const char* line=noteNumbers?digits[row]:letters[row];
int n=strlen(line),gap=1,w=(320-(n-1)*gap)/n,start=(320-(n*(w+gap)-gap))/2,i=(x-start)/(w+gap);
if(i>=0&&i<n&&x>=start+i*(w+gap)&&x<start+i*(w+gap)+w){
char key=line[i];if(!noteNumbers&&!noteCaps)key=tolower((unsigned char)key);
noteText+=key;drawNoteInput();
}
}
return;
}
}
void setup(){
Serial.begin(115200);
launchedAt=millis();
pinMode(TFT_BACKLIGHT_PIN,OUTPUT);
digitalWrite(TFT_BACKLIGHT_PIN,HIGH);
tft.init();
tft.setRotation(0);
Wire.begin(TOUCH_SDA_PIN,TOUCH_SCL_PIN,400000);
touch.begin();
touch.setRotation(1);
SPI.begin(SD_SCK_PIN,SD_MISO_PIN,SD_MOSI_PIN,SD_CS_PIN);
sdCardReady=SD.begin(SD_CS_PIN);
// Earlier firmware filled the ESP32's internal Wi-Fi settings area. Messenger
// stores its credentials on the microSD card, so clearing this stale area is
// safe and gives the Wi-Fi driver room to join the network the user selects.
esp_err_t nvsResult = nvs_flash_erase();
if (nvsResult == ESP_OK) nvs_flash_init();
WiFi.persistent(false);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false);
Messenger::beginAutoConnect();
beginNetworkTime();
resetGames();
drawHome();
}
void loop(){
touch.read();
if(!touch.isTouched){
pixelStrokeActive=false;
pixelLastX=pixelLastY=-1;
// Home is tap-only: releasing a finger never changes pages.
homeSwipeTracking=false;
if(locked){
if(!liftedAt)liftedAt=millis();
else if(millis()-liftedAt>55)locked=false;
}
}else{
liftedAt=0;
int x=touch.points[0].x,y=touch.points[0].y;
if(!locked){
locked=true;
// Open buttons immediately on touch. The page dots remain the only
// launcher control for moving between the two app pages.
if(x>=0&&x<320&&y>=0&&y<480)tap(x,y);
}else if(page==PIXEL&&!pixelPalette&&!pixelNaming&&x>=8&&x<312&&y>=65&&y<369){
pixelStrokeTo((x-8)/19,(y-65)/19);
}
}
// Keep the chat connection listening even while Home, Weather, or another app is open.
// Incoming messages can therefore update the phone-wide notification badge immediately.
Messenger::tick();
beginNetworkTime();
if(page==HOME && millis()/60000!=lastHomeClockDraw){lastHomeClockDraw=millis()/60000;drawHomeClock();}
if(page==CLOCKAPP&&millis()/1000!=lastClockDraw)refreshClock();
// Relative times change once per second, but their small text fields are the
// only part of Notifications repainted. New alerts redraw the list in addNotification().
if(page==NOTIFICATIONS&&millis()/1000!=lastNotificationDraw) refreshNotificationAges();
if((page==CHESS||page==CHECKERS)&&gameMode==AI_OFF_MODE&&!gameDone&&millis()-aiMoveAt>450){
bool aiTurn=(page==CHESS&&!whiteTurn)||(page==CHECKERS&&!redTurn);
if(aiTurn){aiMoveAt=millis();doAiMove();}
}
if((page==CHESS||page==CHECKERS||page==SUDOKU)&&WiFi.status()==WL_CONNECTED){
if(hosting&&(!gamePeer||!gamePeer.connected())){WiFiClient c=gameServer.available();if(c){gamePeer=c;if(gameType==SUDOKU_GAME)sendSudokuState();}}
if(joining&&(!gamePeer||!gamePeer.connected())&&millis()-retryAt>3000){
retryAt=millis();
IPAddress ip;
// Do not let a powered-off second screen stall touch while TCP waits.
if(ip.fromString(savedIp)&&gamePeer.connect(ip,GAME_PORT,80)){if(gameType==SUDOKU_GAME)gamePeer.print("Q\\n");}
}
receiveMove();
}
// Let Wi-Fi run, while returning to touch and drawing on the next millisecond.
delay(1);
}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.




