Community project

Animated App Launcher

ESP32
Photo of Animated App Launcher
Generated with AI

hello world!

Published October 2, 2026

The Animated App Launcher transforms an M5Stack Tab into a customizable home screen with smooth animations, app shortcuts, and interactive features. Built on the ESP32 platform with SD card support, this project delivers a polished user interface complete with a boot animation, lock screen, control center, and gesture recognition.

This guide provides everything needed to set up the launcher: a complete wiring diagram, full parts list, pre-built firmware, and step-by-step assembly instructions. Readers will learn how to configure startup styles, record voice memos, test the drawing interface, and deploy the system via USB power.

Wiring diagram

Assemble it in 6 steps

1. Leave the Tab5 unmodified

This FluxOS build uses the M5Stack Tab5’s built-in screen, touch panel, microphone, and speaker, so do not connect any extra wires or parts.

  • Keep the device on a stable surface while testing touch controls or making a recording.

2. Prepare the optional SD card

If you want the optional wallpaper and SF Pro typeface, put a FAT32 microSD card in the Tab5. Place fluxos.png and the three converted font files in the card’s top-level folder.

  • FluxOS still starts with its plain background and built-in typeface if the card or these files are not present.
  • Do not remove the SD card while Freeform is exporting or moving a drawing, because the saved drawing could be incomplete.

3. Choose the startup style

After FluxOS starts, open Settings and tap Boot screen. When the switch is on, the supplied FluxOS logo appears at startup; when it is off, the text terminal startup screen appears instead. Your choice is remembered after restart.

  • The separate Boot animation switch controls the later slide into the lock screen.

4. Make a voice memo

Open Voice Memos from the launcher, tap Record, speak normally near the top edge of the Tab5, then tap Stop recording. Tap Play latest memo to hear the newest saved note through the Tab5 speaker.

  • Each new recording replaces the previous memo and is kept in the Tab5’s internal Flash storage.
  • Recordings stop automatically after 30 seconds so the device does not fill its internal storage.

5. Test the drawing hold menu

Open Freeform, touch the white drawing page without moving your finger, and keep holding for about one second. The light-gray menu lets you delete the drawing, use it as the current background, or save it to SD card or Flash storage.

  • Touch outside the light-gray panel to close it without changing the drawing.

6. Power the Tab5 by USB

Connect the Tab5 to USB for power, then use Schematik’s Deploy button when you want to install the current FluxOS firmware.

  • Use a data-capable USB cable for deploying firmware; some charging-only cables cannot transfer the program.

Deploy the firmware

#include <Arduino.h>
#include <SD_MMC.h>
#include <M5Unified.h>
#include <M5GFX.h>
#include <Preferences.h>
#include <SPIFFS.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include "mail_icon.h"
#include "messaging_icon.h"
#include "browser_icon.h"
#include "push_icon.h"
#include "settings_icon.h"
#include "voice_memos_icon.h"
#include "gitcode_preview_icon.h"
#include "toggle_on.h"
#include "toggle_off.h"
#include "fluxos_boot_logo.h"

// FluxOS Slethal v2
// The built-in recovery action resets FluxOS session state and restarts. It does
// not claim to install an unavailable firmware image.

enum class ScreenState { Boot, BootReveal, Lock, Home, Opening, App, Settings, ControlCenter, ConfirmReset, Resetting, GestureArmed, Gesture, Screensaver, ScreenOff };

enum class FreeformTool { Pen, Circle, Brush, Fill, Eraser, Square, RoundSquare };

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations
template <typename T> void setStatusBarFont(T &target);

template <typename T> void setSystemFont(T &target, int pixels);

void drawFreeformColorPicker();

void renderAppCache(int i);
void renderFreeformCache();
void renderFilesCache();
void renderFilesImageViewer();
void renderBrowserCache();
void renderVoiceMemosCache();
void renderGitCodeCache();
bool buildGitCodeApp();
void loadGitCodeApp();
void updateVoiceMemosRecording();
void startVoiceMemo();
void stopVoiceMemo();
void playVoiceMemo();
void fetchBrowserPage(const char *query);
void drawBrowserImage();
void beginWiFiOnBoot();
void renderSettingsCache();
void exportFreeformToFiles();
void drawFreeformToolbar(LGFX_Sprite &s);
void drawFreeformHoldMenu();
void setFreeformAsBackground();
void renderLockCache();
void showLock();
void drawWallpaper(LGFX_Sprite &s);
void prepareWallpaperCache();

void drawIconPixelsDisplay(int x, int y, int size, const uint16_t *pixels);
void drawTogglePixels(LGFX_Sprite &s, int x, int y, const uint16_t *pixels);

template <typename T> void drawTopBar(T &target, uint16_t background);
void refreshTopBar(uint32_t now, bool force = false);

uint16_t tab5Color(uint16_t rgb565);
void drawIconPixels(LGFX_Sprite &s, int x, int y, const uint16_t *pixels);

uint16_t blend565(uint16_t from, uint16_t to, float amount);
uint16_t gestureBarColor(uint32_t now);
int gestureBarY(uint32_t now);
void drawIcon(LGFX_Sprite &s, int x, int y, int size, uint16_t color, bool settings);
void drawGestureBar(LGFX_Sprite &s);
void drawHomeContents(LGFX_Sprite &s);
void preloadScreens();
void showHome();
void showApp();
void showSettings();
bool touchPoint(int &x, int &y);
void refreshGestureBar(uint32_t now);
void hideGestureBar();
void drawOpeningFrame(float progress);
void restoreHomeArea(int x, int y, int w, int h);
void drawFloatingCard(float cx, float cy, float scale);
void drawSwitcherBackground(float clarity);
void terminalLine(int row, const char *tag, uint16_t color, const char *message);
void drawBootTerminal(uint32_t elapsed);
void drawBootLogo();
void drawBootReveal(float progress);
void drawConfirmation();
void drawResetting(uint32_t elapsed);
void drawScreensaver();
void drawControlCenter();
bool chargerConnected();

constexpr int APP_COUNT = 6;
constexpr int SETTINGS_APP = 3;
constexpr int VOICE_MEMOS_APP = 4;
constexpr int GITCODE_APP = 5;
constexpr uint16_t UI_BLACK = 0x0000;
constexpr uint16_t UI_WHITE = 0xFFFF;
constexpr uint16_t HOME_BG = 0xE71C;
constexpr uint16_t HOME_TEXT = 0x18C3;
constexpr uint16_t CARD = 0x1082;
constexpr uint16_t GESTURE_GREY = 0xC618;
constexpr uint16_t UI_BLUE = 0x2D9D;
constexpr uint16_t UI_GREEN = 0x05E0;
constexpr uint16_t UI_RED = 0xF800;
// Slightly different neutral grays keep each icon identifiable in code while
// making every app tile a calm gray surface behind the transparent artwork.
constexpr uint16_t APP_COLORS[APP_COUNT] = {0xBDF7, 0xBDF6, 0xBDF5, 0xBDF4, 0xBDF3, 0xBDF2};
constexpr const char *APP_NAMES[APP_COUNT] = {"Freeform", "Search", "Files", "Settings", "Voice Memos", "GitCode Preview"};

// Put your Wi-Fi network name and password between these quotes before you
// deploy. FluxOS never stores or invents network credentials.
constexpr const char *WIFI_SSID = "Home network";
constexpr const char *WIFI_PASSWORD = "CheekyMonkeyz3";
constexpr const char *BROWSER_HOME_URL = "https://example.com";
constexpr uint32_t OPEN_DURATION_MS = 680;
constexpr uint32_t FRAME_MS = 20;
constexpr uint32_t GESTURE_HOLD_MS = 4000;
constexpr uint32_t GESTURE_FADE_MS = 420;
constexpr uint32_t BOOT_LINE_MS = 230;
constexpr uint32_t BOOT_REVEAL_MS = 520;
constexpr int TOP_BAR_HEIGHT = 54;
// Conservative idle defaults: a calm clock appears after one minute, then the
// panel is turned off after two minutes to avoid leaving a bright static image.
constexpr uint32_t SCREENSAVER_DELAY_MS = 60000;
constexpr uint32_t SCREEN_TIMEOUT_MS = 120000;

Preferences preferences;
LGFX_Sprite homeScreen(&M5.Display);
LGFX_Sprite appScreens[APP_COUNT] = {LGFX_Sprite(&M5.Display), LGFX_Sprite(&M5.Display), LGFX_Sprite(&M5.Display), LGFX_Sprite(&M5.Display), LGFX_Sprite(&M5.Display), LGFX_Sprite(&M5.Display)};
LGFX_Sprite settingsScreen(&M5.Display);
LGFX_Sprite lockScreen(&M5.Display);
// This is the already-rotated portrait wallpaper. The SD PNG is decoded only
// once during startup, never while a touch animation is running.
LGFX_Sprite wallpaperCache(&M5.Display);
// Boot frames are assembled in PSRAM too, so terminal lines and the Home reveal
// never appear on the panel while they are only partly drawn.
LGFX_Sprite bootBuffer(&M5.Display);
// The switcher is composed off-screen in PSRAM. Nothing is sent to the panel
// until a complete frame is ready, avoiding half-drawn cards and touch trails.
LGFX_Sprite switcherBuffer(&M5.Display);

ScreenState screenState = ScreenState::Boot;
int screenW = 0, screenH = 0, iconSize = 0, appIconSize = 0, appIconX = 0, appIconY = 0;
int homeIconX[APP_COUNT], homeIconY[APP_COUNT];
// The dock repeats the four real launch targets as quick-launch controls. It
// is deliberately a home-screen affordance, not a separate set of fake apps.
int dockIconX[APP_COUNT], dockIconY = 0, dockIconSize = 0;
int selectedApp = 0, gestureStartY = 0, gestureLastY = 0;
int oldPanelX = 0, oldPanelY = 0, oldPanelW = 0, oldPanelH = 0;
uint32_t animationStartedAt = 0, lastFrameAt = 0, gestureTappedAt = 0, gestureStartedAt = 0, bootStartedAt = 0, bootRevealStartedAt = 0, resetStartedAt = 0;
float floatingCX = 0, floatingCY = 0, floatingScale = 1.0f;
bool hasOldPanel = false, touchWasDown = false, showResetTerminal = false, bootAnimationEnabled = true, logoBootEnabled = true;
// Control Center is a modal shell surface. It returns to the exact screen that
// was visible before a top-edge pull, rather than opening a separate app.
ScreenState controlCenterReturn = ScreenState::Home;
bool topEdgeTracking = false;
int topEdgeStartY = 0;
bool screensaverEnabled = true, screenTimeoutEnabled = true, keepAwakeWhileCharging = true;
uint32_t lastUserActivityAt = 0;
// The optional wallpaper is read from the Tab5 microSD card during boot.
// Use a FAT32 card with a 720 x 1280 PNG at /fluxos.png.
bool sdWallpaperAvailable = false;
// Optional SD-card system typeface. Place all three converted files in the
// FAT32 card root: /SFPro16px.vlw, /SFPro26px.vlw, and /SFPro48px.vlw.
bool sfProAvailable = false;
int bootCacheStage = 0;
uint32_t lastTopBarUpdateAt = 0;
int shownMinute = -1, shownBattery = -999;
// Freeform keeps its drawing in the app's existing RAM screen, so strokes stay
// visible while the app is open without repeatedly reading the SD card.

uint16_t freeformInk = 0xF800;
FreeformTool freeformTool = FreeformTool::Pen;
int freeformLastX = -1, freeformLastY = -1, freeformStartX = -1, freeformStartY = -1;
bool freeformDrawing = false, freeformCanvasReady = false, freeformColorPicker = false;
bool freeformStrokeMoved = false, freeformHoldMenu = false;
uint32_t freeformTouchStartedAt = 0;
bool filesUseSd = true, filesHasExport = false, filesViewingImage = false;
uint16_t customRed = 31, customGreen = 0, customBlue = 0;
int filesSequence = 1;
String browserUrl = BROWSER_HOME_URL;
String browserQuery = "M5Stack Tab5";
String browserImageUrl = "https://loremflickr.com/640/320/technology";
String browserStatus = "Wi-Fi is starting during boot";
String browserText = "Choose a topic, then tap Search. Results and a related image load over Wi-Fi.";
String browserOverview = "AI overview appears here after a search.";
bool wifiBootStarted = false;

// GitCode Preview turns a root-level FluxUI Lua source file into a small,
// versioned .app package on the SD card. This first safe runtime intentionally
// accepts only FluxUI declarations: it does not execute arbitrary Lua or expose
// raw filesystem/network access to downloaded code.
constexpr const char *GITCODE_LUA_PATH = "/gitcode.lua";
constexpr const char *GITCODE_APP_PATH = "/gitcode.app";
bool gitcodeInstalled = false, gitcodeHasApp = false;
String gitcodeStatus = "Build a root-level gitcode.lua file.";
// FluxUI packages are user-space documents: the parser accepts declarations
// only, so a downloaded package cannot call the raw board, network, display,
// audio, or filesystem APIs used by FluxOS itself.
String gitcodeTitle = "My Flux App";
String gitcodeBody = "Create /gitcode.lua on the SD card, then tap Build.";
uint16_t gitcodeAccent = UI_BLUE;

// Voice Memos uses the Tab5's integrated microphone and speaker. Audio is
// captured as 16-bit mono PCM at a speech-friendly 16 kHz and saved in Flash.
constexpr uint32_t VOICE_SAMPLE_RATE = 16000;
constexpr size_t VOICE_BLOCK_SAMPLES = 512;
constexpr uint32_t VOICE_MAX_SECONDS = 30;
constexpr const char *VOICE_PATH = "/voice-memo.pcm";
int16_t voiceBlock[VOICE_BLOCK_SAMPLES];
int16_t *voicePlayback = nullptr;
size_t voicePlaybackSamples = 0;
size_t voiceRecordedSamples = 0;
uint32_t voiceRecordedAt = 0;
bool voiceRecording = false, voicePlaying = false, voiceHasMemo = false;
int voiceWaveLevel = 0;

template <typename T> void setSystemFont(T &target, int pixels) {
  // Use the supplied VLW faces when all three files are present. unloadFont()
  // closes the old file before another size is opened, so the boot sprites do
  // not accumulate SD-card file handles.
  target.unloadFont();
  if (sfProAvailable) {
    const char *path = pixels >= 48 ? "/SFPro48px.vlw" : (pixels >= 26 ? "/SFPro26px.vlw" : "/SFPro16px.vlw");
    if (target.loadFont(SD_MMC, path)) return;
  }
  target.setFont(pixels >= 48 ? &fonts::Font6 : (pixels >= 26 ? &fonts::Font4 : &fonts::Font2));
}

template <typename T> void setStatusBarFont(T &target) {
  target.unloadFont();
  if (sfProAvailable && target.loadFont(SD_MMC, "/SFPro16px.vlw")) return;
  target.setFont(&fonts::Font2);
}

uint16_t blend565(uint16_t from, uint16_t to, float amount) {
  amount = constrain(amount, 0.0f, 1.0f);
  int fr = (from >> 11) & 31, fg = (from >> 5) & 63, fb = from & 31;
  int tr = (to >> 11) & 31, tg = (to >> 5) & 63, tb = to & 31;
  return ((fr + (int)((tr - fr) * amount)) << 11) | ((fg + (int)((tg - fg) * amount)) << 5) | (fb + (int)((tb - fb) * amount));
}

uint16_t gestureBarColor(uint32_t now) {
  if (!gestureTappedAt) return GESTURE_GREY;
  uint32_t age = now - gestureTappedAt;
  if (age <= GESTURE_HOLD_MS) return UI_WHITE;
  if (age >= GESTURE_HOLD_MS + GESTURE_FADE_MS) return GESTURE_GREY;
  return blend565(UI_WHITE, GESTURE_GREY, (age - GESTURE_HOLD_MS) / (float)GESTURE_FADE_MS);
}

int gestureBarY(uint32_t now) {
  int resting = screenH - max(42, screenH / 24);
  if (!gestureTappedAt || now - gestureTappedAt >= 360) return resting;
  return resting - (int)(sinf((now - gestureTappedAt) * PI / 360.0f) * max(8, screenH / 55));
}

// M5GFX uses the same RGB565 color order as the generated image headers.
// Keeping pixels unchanged prevents the supplied icon and switch colours from
// being red/blue swapped on the Tab5 display.
uint16_t tab5Color(uint16_t rgb565) { return rgb565; }

void drawIconPixels(LGFX_Sprite &s, int x, int y, const uint16_t *pixels) {
  for (int row = 0; row < 96; ++row) {
    for (int col = 0; col < 96; ++col) {
      uint16_t pixel = pgm_read_word(pixels + row * 96 + col);
      if (pixel != 0x0000) s.drawPixel(x + col, y + row, tab5Color(pixel));
    }
  }
}

void drawIconPixelsDisplay(int x, int y, int size, const uint16_t *pixels) {
  // The supplied artwork is 96 square pixels. Nearest-neighbour scaling is
  // deliberate here: it stays crisp during the live switcher gesture.
  for (int row = 0; row < size; ++row) {
    int sourceRow = row * 96 / size;
    for (int col = 0; col < size; ++col) {
      uint16_t pixel = pgm_read_word(pixels + sourceRow * 96 + (col * 96 / size));
      if (pixel != 0x0000) M5.Display.drawPixel(x + col, y + row, tab5Color(pixel));
    }
  }
}

// The supplied switch images are composited into the hidden Settings sprite.
// Their black border pixels stay transparent so the Settings card shows through.
void drawTogglePixels(LGFX_Sprite &s, int x, int y, const uint16_t *pixels) {
  for (int row = 0; row < TOGGLE_ON_HEIGHT; ++row) {
    for (int col = 0; col < TOGGLE_ON_WIDTH; ++col) {
      uint16_t pixel = pgm_read_word(pixels + row * TOGGLE_ON_WIDTH + col);
      if (pixel != 0x0000) s.drawPixel(x + col, y + row, tab5Color(pixel));
    }
  }
}

void drawIcon(LGFX_Sprite &s, int x, int y, int size, uint16_t color, bool settings = false) {
  int ix = x + (size - 96) / 2;
  int iy = y + (size - 96) / 2;
  if (color == APP_COLORS[0]) drawIconPixels(s, ix, iy, MAIL_ICON_DATA);
  else if (color == APP_COLORS[1]) drawIconPixels(s, ix, iy, BROWSER_ICON_DATA);
  else if (color == APP_COLORS[2]) drawIconPixels(s, ix, iy, PUSH_ICON_DATA);
  else if (color == APP_COLORS[3]) drawIconPixels(s, ix, iy, SETTINGS_ICON_DATA);
  else if (color == APP_COLORS[4]) drawIconPixels(s, ix, iy, VOICE_MEMOS_ICON_DATA);
  else drawIconPixels(s, ix, iy, GITCODE_PREVIEW_ICON_DATA);
}

void drawGestureBar(LGFX_Sprite &s) {
  // Original FluxOS gesture bar: a lightweight rounded line, drawn directly
  // instead of using an image so it stays responsive during touch animations.
  const int w = max(96, screenW / 5);
  const int h = max(6, screenH / 160);
  const int x = (screenW - w) / 2;
  const int y = gestureBarY(millis());
  s.fillRoundRect(x, y, w, h, h / 2, gestureBarColor(millis()));
}

// The Tab5's RX8130CE RTC keeps the device's local clock. The power manager
// returns a percentage when a battery is present; USB-only operation is shown
// as a dash rather than a made-up percentage.
template <typename T> void drawTopBar(T &target, uint16_t background) {
  // FluxOS draws its own compact black status bar. It uses the Tab5 RTC rather
  // than a network clock, so it also works without Wi-Fi.
  target.fillRect(0, 0, screenW, TOP_BAR_HEIGHT, UI_BLACK);
  auto dt = M5.Rtc.getDateTime();
  char dateText[20];
  char timeText[8];
  snprintf(dateText, sizeof(dateText), "%02d/%02d/%04d", dt.date.month, dt.date.date, dt.date.year);
  snprintf(timeText, sizeof(timeText), "%02d:%02d", dt.time.hours, dt.time.minutes);
  int battery = M5.Power.getBatteryLevel();
  char batteryText[12];
  if (battery >= 0 && battery <= 100) snprintf(batteryText, sizeof(batteryText), "%d%%", battery);
  else snprintf(batteryText, sizeof(batteryText), "--");

  setStatusBarFont(target);
  target.setTextColor(UI_WHITE, UI_BLACK);
  target.setTextDatum(textdatum_t::middle_left);
  target.drawString(dateText, 18, TOP_BAR_HEIGHT / 2);
  target.setTextDatum(textdatum_t::middle_right);
  target.drawString(batteryText, screenW - 18, TOP_BAR_HEIGHT / 2);
  int batteryWidth = target.textWidth(batteryText);
  target.drawString(timeText, screenW - 32 - batteryWidth, TOP_BAR_HEIGHT / 2);
}

void refreshTopBar(uint32_t now, bool force) {
  if (screenState != ScreenState::Home && screenState != ScreenState::App && screenState != ScreenState::Settings) return;
  if (!force && now - lastTopBarUpdateAt < 1000) return;
  lastTopBarUpdateAt = now;
  auto dt = M5.Rtc.getDateTime();
  int minute = dt.time.hours * 60 + dt.time.minutes;
  int battery = M5.Power.getBatteryLevel();
  if (!force && minute == shownMinute && battery == shownBattery) return;
  shownMinute = minute; shownBattery = battery;
  drawTopBar(M5.Display, UI_BLACK);
}

void prepareWallpaperCache() {
  wallpaperCache.fillSprite(HOME_BG);
  if (!sdWallpaperAvailable) return;

  // Decode the landscape artwork once, rotate it into portrait, then use a
  // centred "cover" crop. This keeps its aspect ratio intact, fills the whole
  // screen, and trims equal amounts from opposite edges rather than pinning it
  // to the top-right corner.
  constexpr int sourceW = 1280, sourceH = 720;
  LGFX_Sprite landscape(&M5.Display);
  landscape.setColorDepth(16);
  landscape.setPsram(true);
  if (!landscape.createSprite(sourceW, sourceH)) { sdWallpaperAvailable = false; return; }
  landscape.fillSprite(HOME_BG);
  landscape.drawPngFile(SD_MMC, "/fluxos.png", 0, 0);
  const float rotatedW = sourceH, rotatedH = sourceW;
  const float scale = max(screenW / rotatedW, screenH / rotatedH);
  const float offsetX = (screenW - rotatedW * scale) * 0.5f;
  const float offsetY = (screenH - rotatedH * scale) * 0.5f;
  for (int y = 0; y < screenH; ++y) {
    for (int x = 0; x < screenW; ++x) {
      int rotatedX = constrain((int)((x - offsetX) / scale), 0, sourceH - 1);
      int rotatedY = constrain((int)((y - offsetY) / scale), 0, sourceW - 1);
      int sourceX = rotatedY;
      int sourceY = sourceH - 1 - rotatedX;
      wallpaperCache.drawPixel(x, y, landscape.readPixel(sourceX, sourceY));
    }
  }
  landscape.deleteSprite();
}

void drawWallpaper(LGFX_Sprite &s) {
  if (!wallpaperCache.getBuffer()) {
    s.fillSprite(HOME_BG);
    return;
  }
  // Do not copy raw sprite memory: M5GFX sprites may use a padded row stride,
  // which can leave a large unpainted block at one edge. Copy visible pixels
  // through the sprite API while caches are being prepared at boot.
  for (int y = 0; y < screenH; ++y) {
    for (int x = 0; x < screenW; ++x) {
      s.drawPixel(x, y, wallpaperCache.readPixel(x, y));
    }
  }
}

void drawHomeContents(LGFX_Sprite &s) {
  drawWallpaper(s);

  // A spacious, wallpaper-first launcher: the four installed apps sit in a
  // familiar icon grid, and the translucent dock keeps the most-used targets
  // reachable at the bottom without hiding the wallpaper.
  s.setTextDatum(textdatum_t::top_center); setSystemFont(s, 16);
  for (int i = 0; i < APP_COUNT; ++i) {
    drawIcon(s, homeIconX[i], homeIconY[i], iconSize, APP_COLORS[i], i == SETTINGS_APP);
    s.setTextColor(UI_WHITE);
    s.drawString(APP_NAMES[i], homeIconX[i] + iconSize / 2, homeIconY[i] + iconSize + 12);
  }

  const int dockX = 22, dockW = screenW - 44, dockH = dockIconSize + 42;
  const int dockY = dockIconY - 20;
  // Two nested neutral panels make a restrained glass-like dock without
  // needing alpha blending or continuous full-screen redraws.
  s.fillRoundRect(dockX, dockY, dockW, dockH, 30, 0x8410);
  s.drawRoundRect(dockX + 2, dockY + 2, dockW - 4, dockH - 4, 28, 0xCE59);
  for (int i = 0; i < APP_COUNT; ++i) {
    drawIcon(s, dockIconX[i], dockIconY, dockIconSize, APP_COLORS[i], i == SETTINGS_APP);
  }
  // Page dots are visual only: FluxOS currently has one launcher page.
  s.fillCircle(screenW / 2 - 8, dockY - 22, 4, UI_WHITE);
  s.fillCircle(screenW / 2 + 8, dockY - 22, 4, 0x9CF3);
  drawGestureBar(s);
  drawTopBar(s, UI_BLACK);
}

// Each cache is rendered into its PSRAM sprite during boot. Keeping this work
// separate makes the boot messages truthful: no app screen is marked ready
// until its complete display-sized RAM image exists.
void drawFreeformToolbar(LGFX_Sprite &s) {
  const char *tools[] = {"Pen", "Circle", "Brush", "Fill", "Erase", "Square", "Round"};
  s.fillRect(0, TOP_BAR_HEIGHT, screenW, 152, UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK);
  s.drawString("Freeform", 22, TOP_BAR_HEIGHT + 10);

  // A compact segmented tab bar follows the supplied reference: a pale gray
  // pill, a white selected tab, and a clear label for every drawing tool.
  int tabX = 16, tabY = TOP_BAR_HEIGHT + 50, tabW = screenW - 32, tabH = 42;
  s.fillRoundRect(tabX, tabY, tabW, tabH, tabH / 2, 0xBDF7);
  int segment = tabW / 7;
  for (int i = 0; i < 7; ++i) {
    int x = tabX + i * segment;
    bool active = (int)freeformTool == i;
    if (active) s.fillRoundRect(x + 3, tabY + 3, segment - 6, tabH - 6, 18, UI_WHITE);
    s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 16);
    s.setTextColor(active ? UI_BLACK : 0x2104, active ? UI_WHITE : 0xBDF7);
    s.drawString(tools[i], x + segment / 2, tabY + tabH / 2);
  }

  int colorY = TOP_BAR_HEIGHT + 110;
  s.fillRoundRect(16, colorY - 8, 166, 44, 18, 0x2104);
  s.fillCircle(40, colorY + 14, 14, freeformInk); s.drawCircle(40, colorY + 14, 17, UI_WHITE);
  s.setTextDatum(textdatum_t::middle_left); setSystemFont(s, 16); s.setTextColor(UI_WHITE, 0x2104);
  s.drawString("Colours", 68, colorY + 14);
  s.fillRoundRect(screenW - 154, colorY - 8, 138, 44, 22, UI_BLUE);
  s.fillCircle(screenW - 130, colorY + 14, 11, UI_WHITE);
  s.setTextDatum(textdatum_t::middle_center); s.setTextColor(UI_BLUE, UI_WHITE); s.drawString("✓", screenW - 130, colorY + 14);
  s.setTextDatum(textdatum_t::middle_left); s.setTextColor(UI_WHITE, UI_BLUE); s.drawString("Export", screenW - 112, colorY + 14);
}

void drawFreeformColorPicker() {
  const int w = min(screenW - 56, 520), h = min(screenH - 180, 610);
  const int x = (screenW - w) / 2, y = max(72, (screenH - h) / 2);
  M5.Display.fillRoundRect(x - 4, y - 4, w + 8, h + 8, 28, UI_BLACK);
  M5.Display.fillRoundRect(x, y, w, h, 26, 0xE71C);
  M5.Display.setTextDatum(textdatum_t::middle_left); setSystemFont(M5.Display, 26); M5.Display.setTextColor(UI_BLACK, 0xE71C);
  M5.Display.drawString("Colours", x + 52, y + 34);
  M5.Display.setTextDatum(textdatum_t::middle_center); M5.Display.drawString("×", x + w - 34, y + 34);
  M5.Display.fillRoundRect(x + 18, y + 62, w - 36, 34, 17, 0xC618);
  M5.Display.fillRoundRect(x + 20, y + 64, (w - 42) / 3, 30, 15, UI_WHITE);
  setSystemFont(M5.Display, 16); M5.Display.setTextColor(UI_BLACK, UI_WHITE); M5.Display.drawString("Grid", x + 20 + (w - 42) / 6, y + 79);
  M5.Display.setTextColor(UI_BLACK, 0xC618); M5.Display.drawString("Spectrum", x + w / 2, y + 79); M5.Display.drawString("Sliders", x + w - 20 - (w - 42) / 6, y + 79);

  // A saturated-to-white colour grid gives a fast finger-friendly picker.
  const int gx = x + 22, gy = y + 112, gw = w - 44, gh = min(250, h - 250);
  for (int row = 0; row < 8; ++row) for (int col = 0; col < 12; ++col) {
    float hue = col / 12.0f, value = 1.0f - row * 0.105f, sat = row < 6 ? 0.86f : 0.35f;
    float rr = fabsf(hue * 6 - 3) - 1, gg = 2 - fabsf(hue * 6 - 2), bb = 2 - fabsf(hue * 6 - 4);
    rr = constrain(rr, 0, 1); gg = constrain(gg, 0, 1); bb = constrain(bb, 0, 1);
    uint16_t c = ((int)(31 * (1 - sat + sat * rr) * value) << 11) | ((int)(63 * (1 - sat + sat * gg) * value) << 5) | (int)(31 * (1 - sat + sat * bb) * value);
    M5.Display.fillRect(gx + col * gw / 12, gy + row * gh / 8, gw / 12 + 1, gh / 8 + 1, c);
  }
  M5.Display.setTextDatum(textdatum_t::top_left); M5.Display.setTextColor(UI_BLACK, 0xE71C); M5.Display.drawString("Opacity", gx, gy + gh + 18);
  M5.Display.fillRoundRect(gx, gy + gh + 46, gw, 22, 11, 0x7BEF); M5.Display.fillCircle(gx + gw - 12, gy + gh + 57, 14, freeformInk);
  const uint16_t swatches[] = {0xB24B, 0x05E0, 0xFFE0, 0xFC60, 0xF9E7, 0x001F, 0x51AF, 0xF809, 0xA145};
  for (int i = 0; i < 9; ++i) M5.Display.fillCircle(gx + 22 + (i % 5) * ((gw - 44) / 4), gy + gh + 104 + (i / 5) * 42, 14, swatches[i]);
}

void renderFreeformCache() {
  LGFX_Sprite &s = appScreens[0];
  const int paperY = TOP_BAR_HEIGHT + 152;
  if (!freeformCanvasReady) {
    s.fillSprite(UI_BLACK);
    s.fillRoundRect(18, paperY, screenW - 36, screenH - paperY - 76, 18, UI_WHITE);
    freeformCanvasReady = true;
  }
  drawFreeformToolbar(s);
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

// A compact search page requests public DuckDuckGo HTML, strips its markup,
// and creates a short on-device overview from the returned result text.
void renderVoiceMemosCache() {
  LGFX_Sprite &s = appScreens[VOICE_MEMOS_APP];
  s.fillSprite(UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK);
  s.drawString("Voice Memos", 22, TOP_BAR_HEIGHT + 16);
  setSystemFont(s, 16); s.setTextColor(0xC618, UI_BLACK);
  if (voiceRecording) s.drawString("Recording to Flash", 22, TOP_BAR_HEIGHT + 55);
  else if (voicePlaying) s.drawString("Playing your latest memo", 22, TOP_BAR_HEIGHT + 55);
  else if (voiceHasMemo) s.drawString("Latest memo saved in Flash", 22, TOP_BAR_HEIGHT + 55);
  else s.drawString("Record a short note with the built-in microphone", 22, TOP_BAR_HEIGHT + 55);
  const int waveTop = TOP_BAR_HEIGHT + 100, waveBottom = screenH - 218;
  s.fillRoundRect(22, waveTop, screenW - 44, waveBottom - waveTop, 18, 0x1082);
  s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 16); s.setTextColor(0x7BEF, 0x1082);
  s.drawString(voiceRecording ? "Listening... tap Stop when finished" : (voiceHasMemo ? "Your newest memo is ready to play" : "Tap Record to begin"), screenW / 2, waveTop + 38);
  int center = (waveTop + waveBottom) / 2;
  for (int i = 0; i < 36; ++i) {
    int h = voiceRecording ? max(5, (voiceWaveLevel * (6 + (i * 17) % 23)) / 32) : 5;
    s.fillRoundRect(48 + i * (screenW - 96) / 36, center - h, 5, h * 2, 3, voiceRecording ? UI_RED : 0x7BEF);
  }
  const int buttonY = screenH - 168;
  s.fillRoundRect(36, buttonY, screenW - 72, 62, 31, voiceRecording ? UI_RED : UI_BLUE);
  s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 26); s.setTextColor(UI_WHITE, voiceRecording ? UI_RED : UI_BLUE);
  s.drawString(voiceRecording ? "■  Stop recording" : "●  Record", screenW / 2, buttonY + 31);
  s.fillRoundRect(36, buttonY + 78, screenW - 72, 46, 23, voiceHasMemo && !voiceRecording ? 0x4208 : 0x2104);
  setSystemFont(s, 16); s.setTextColor(voiceHasMemo && !voiceRecording ? UI_WHITE : 0x7BEF, voiceHasMemo && !voiceRecording ? 0x4208 : 0x2104);
  s.drawString(voicePlaying ? "Playing..." : "▶  Play latest memo", screenW / 2, buttonY + 101);
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

void startVoiceMemo() {
  if (voicePlaying) { M5.Speaker.stop(); voicePlaying = false; }
  SPIFFS.remove(VOICE_PATH);
  File f = SPIFFS.open(VOICE_PATH, FILE_WRITE);
  if (!f) return;
  f.close();
  voiceRecordedSamples = 0; voiceRecordedAt = millis(); voiceWaveLevel = 0;
  // Keep the Tab5 speaker service alive while the microphone starts. Ending
  // and recreating the audio services on every tap could leave Record looking
  // briefly active before it returned to the idle button.
  M5.Speaker.stop();
  M5.Mic.begin();
  voiceRecording = true; voiceHasMemo = false;
}

void stopVoiceMemo() {
  if (!voiceRecording) return;
  voiceRecording = false;
  voiceHasMemo = voiceRecordedSamples > 0;
  M5.Mic.end();
}

void updateVoiceMemosRecording() {
  if (!voiceRecording) return;
  if ((millis() - voiceRecordedAt) >= VOICE_MAX_SECONDS * 1000UL) stopVoiceMemo();
  if (!voiceRecording || !M5.Mic.record(voiceBlock, VOICE_BLOCK_SAMPLES, VOICE_SAMPLE_RATE)) return;
  File f = SPIFFS.open(VOICE_PATH, FILE_APPEND);
  if (!f || f.write(reinterpret_cast<const uint8_t *>(voiceBlock), sizeof(voiceBlock)) != sizeof(voiceBlock)) { if (f) f.close(); stopVoiceMemo(); return; }
  f.close(); voiceRecordedSamples += VOICE_BLOCK_SAMPLES;
  int32_t peak = 0; for (size_t i = 0; i < VOICE_BLOCK_SAMPLES; ++i) peak = max(peak, abs((int32_t)voiceBlock[i]));
  voiceWaveLevel = constrain(peak / 500, 2, 60);
}

void playVoiceMemo() {
  if (!voiceHasMemo || voiceRecording || voicePlaying) return;
  File f = SPIFFS.open(VOICE_PATH, FILE_READ); size_t bytes = f ? f.size() : 0;
  if (!f || bytes < sizeof(int16_t)) { if (f) f.close(); voiceHasMemo = false; return; }
  if (voicePlayback) { free(voicePlayback); voicePlayback = nullptr; }
  voicePlayback = static_cast<int16_t *>(malloc(bytes));
  if (!voicePlayback || f.read(reinterpret_cast<uint8_t *>(voicePlayback), bytes) != bytes) { if (voicePlayback) free(voicePlayback); voicePlayback = nullptr; f.close(); return; }
  f.close(); voicePlaybackSamples = bytes / sizeof(int16_t); voicePlaying = true;
  M5.Speaker.playRaw(voicePlayback, voicePlaybackSamples, VOICE_SAMPLE_RATE, false, 1, 0, true);
}

bool buildGitCodeApp() {
  if (SD_MMC.cardType() == CARD_NONE || !SD_MMC.exists(GITCODE_LUA_PATH)) {
    gitcodeStatus = "Put gitcode.lua in the SD card root.";
    return false;
  }
  File source = SD_MMC.open(GITCODE_LUA_PATH, FILE_READ);
  if (!source || source.size() > 16384) { if (source) source.close(); gitcodeStatus = "Source is missing or too large."; return false; }
  String title = "My Flux App", body = "Hello from FluxUI", colour = "#2D9D";
  while (source.available()) {
    String line = source.readStringUntil('\n'); line.trim();
    if (line.startsWith("ui.title(")) { int a = line.indexOf('\"'), b = line.lastIndexOf('\"'); if (a >= 0 && b > a) title = line.substring(a + 1, b); }
    if (line.startsWith("ui.text(")) { int a = line.indexOf('\"'), b = line.lastIndexOf('\"'); if (a >= 0 && b > a) body = line.substring(a + 1, b); }
    if (line.startsWith("ui.color(")) { int a = line.indexOf('#'), b = line.indexOf('\"', a); if (a >= 0 && b > a) colour = line.substring(a, b); }
  }
  source.close();
  if (title.length() > 40 || body.length() > 160 || colour.length() != 7 || colour[0] != '#') { gitcodeStatus = "Use short FluxUI title, text, and #RRGGBB color."; return false; }
  File app = SD_MMC.open(GITCODE_APP_PATH, FILE_WRITE);
  if (!app) { gitcodeStatus = "Could not write gitcode.app."; return false; }
  bool ok = app.print("FLUXAPP1\n") && app.println(title) && app.println(colour) && app.println(body);
  app.close();
  if (!ok) { SD_MMC.remove(GITCODE_APP_PATH); gitcodeStatus = "Build failed; partial app removed."; return false; }
  gitcodeTitle = title; gitcodeBody = body;
  long rgb = strtol(colour.substring(1).c_str(), nullptr, 16);
  gitcodeAccent = ((rgb >> 19) & 31) << 11 | ((rgb >> 10) & 63) << 5 | ((rgb >> 3) & 31);
  gitcodeHasApp = true; gitcodeStatus = "Built /gitcode.app - preview is live.";
  return true;
}

void loadGitCodeApp() {
  gitcodeHasApp = false;
  if (SD_MMC.cardType() == CARD_NONE || !SD_MMC.exists(GITCODE_APP_PATH)) return;
  File app = SD_MMC.open(GITCODE_APP_PATH, FILE_READ);
  if (!app || app.readStringUntil('\n') != "FLUXAPP1\r") { if (app) app.close(); return; }
  String title = app.readStringUntil('\n'); String colour = app.readStringUntil('\n'); String body = app.readStringUntil('\n'); app.close();
  title.trim(); colour.trim(); body.trim();
  if (!title.length() || colour.length() != 7 || colour[0] != '#') return;
  gitcodeTitle = title; gitcodeBody = body; long rgb = strtol(colour.substring(1).c_str(), nullptr, 16);
  gitcodeAccent = ((rgb >> 19) & 31) << 11 | ((rgb >> 10) & 63) << 5 | ((rgb >> 3) & 31); gitcodeHasApp = true;
}

void renderGitCodeCache() {
  LGFX_Sprite &s = appScreens[GITCODE_APP]; s.fillSprite(UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK); s.drawString("GitCode Preview", 22, TOP_BAR_HEIGHT + 16);
  setSystemFont(s, 16); s.setTextColor(0xC618, UI_BLACK); s.drawString(gitcodeInstalled ? gitcodeStatus : "Download this app from Settings first.", 22, TOP_BAR_HEIGHT + 56);
  s.setTextColor(0x7BEF, UI_BLACK); s.drawString("Runs in restricted Flux user-space", 22, TOP_BAR_HEIGHT + 78);
  int cardY = TOP_BAR_HEIGHT + 104;
  s.fillRoundRect(22, cardY, screenW - 44, screenH - cardY - 154, 20, gitcodeHasApp ? gitcodeAccent : CARD);
  s.setTextDatum(textdatum_t::top_center); setSystemFont(s, 48); s.setTextColor(UI_WHITE, gitcodeHasApp ? gitcodeAccent : CARD); s.drawString(gitcodeHasApp ? gitcodeTitle : "FluxUI", screenW / 2, cardY + 56);
  setSystemFont(s, 26); s.drawString(gitcodeHasApp ? gitcodeBody : "No built app yet", screenW / 2, cardY + 132);
  s.fillRoundRect(36, screenH - 132, screenW - 72, 54, 27, gitcodeInstalled ? UI_BLUE : 0x4208);
  setSystemFont(s, 26); s.drawString(gitcodeInstalled ? "Build SD Lua file" : "Download in Settings", screenW / 2, screenH - 105);
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

void renderBrowserCache() {
  LGFX_Sprite &s = appScreens[1]; s.fillSprite(UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK); s.drawString("Search", 22, TOP_BAR_HEIGHT + 12);
  s.fillRoundRect(screenW - 146, TOP_BAR_HEIGHT + 12, 124, 40, 20, UI_BLUE); s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 16); s.setTextColor(UI_WHITE, UI_BLUE); s.drawString("Search", screenW - 84, TOP_BAR_HEIGHT + 32);
  s.fillRoundRect(18, TOP_BAR_HEIGHT + 62, screenW - 36, 42, 12, CARD); s.setTextDatum(textdatum_t::middle_left); s.setTextColor(UI_WHITE, CARD); s.drawString(browserQuery, 32, TOP_BAR_HEIGHT + 83);
  const char *topics[] = {"Technology", "Nature", "Space"};
  for (int i = 0; i < 3; ++i) { int x = 18 + i * (screenW - 44) / 3; int w = (screenW - 56) / 3; s.fillRoundRect(x, TOP_BAR_HEIGHT + 114, w, 32, 16, 0x4208); s.setTextDatum(textdatum_t::middle_center); s.setTextColor(UI_WHITE, 0x4208); s.drawString(topics[i], x + w / 2, TOP_BAR_HEIGHT + 130); }
  s.fillRoundRect(18, TOP_BAR_HEIGHT + 158, screenW - 36, 34, 10, CARD); s.setTextDatum(textdatum_t::middle_left); s.setTextColor(0xC618, CARD); s.drawString(browserStatus, 30, TOP_BAR_HEIGHT + 175);
  s.fillRoundRect(18, TOP_BAR_HEIGHT + 204, screenW - 36, 178, 12, 0x2104); s.setTextDatum(textdatum_t::middle_center); s.setTextColor(0x7BEF, 0x2104); s.drawString("Related image loads after search", screenW / 2, TOP_BAR_HEIGHT + 293);
  int overviewY = TOP_BAR_HEIGHT + 398; s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 16); s.setTextColor(UI_BLUE, UI_BLACK); s.drawString("AI OVERVIEW", 24, overviewY);
  s.setTextColor(UI_WHITE, UI_BLACK); String text = browserOverview + " " + browserText; int line = 0, start = 0, chars = max(28, screenW / 11); int maxLines = max(4, (screenH - overviewY - 104) / 22);
  while (start < text.length() && line < maxLines) { int end = min((int)text.length(), start + chars); if (end < text.length()) { int space = text.lastIndexOf(' ', end); if (space > start) end = space; } String part = text.substring(start, end); part.trim(); if (part.length()) s.drawString(part, 24, overviewY + 26 + line++ * 22); start = end + 1; }
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

void beginWiFiOnBoot() {
  if (wifiBootStarted || String(WIFI_SSID) == "YOUR_WIFI_NAME" || String(WIFI_PASSWORD) == "YOUR_WIFI_PASSWORD") return;
  wifiBootStarted = true; WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}

void drawBrowserImage() {
  if (WiFi.status() != WL_CONNECTED || browserImageUrl.length() == 0) return;

  // M5GFX on the Tab5 renders JPEG data from memory, rather than providing a
  // URL helper. Download the related image, draw it once, then release its RAM.
  WiFiClientSecure imageClient;
  imageClient.setInsecure();
  HTTPClient imageHttp;
  if (!imageHttp.begin(imageClient, browserImageUrl)) return;
  imageHttp.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  imageHttp.setTimeout(15000);
  const int status = imageHttp.GET();
  const int length = imageHttp.getSize();
  if (status < 200 || status >= 400 || length <= 0 || length > 700000) {
    imageHttp.end();
    return;
  }

  uint8_t *jpeg = static_cast<uint8_t *>(malloc(length));
  if (!jpeg) { imageHttp.end(); return; }
  WiFiClient *stream = imageHttp.getStreamPtr();
  size_t received = 0;
  uint32_t lastDataAt = millis();
  while (received < (size_t)length && millis() - lastDataAt < 15000) {
    const size_t available = stream->available();
    if (available) {
      const size_t count = min(available, (size_t)length - received);
      const int read = stream->readBytes(jpeg + received, count);
      if (read > 0) { received += read; lastDataAt = millis(); }
    } else {
      delay(5);
    }
  }
  imageHttp.end();
  if (received == (size_t)length) {
    M5.Display.drawJpg(jpeg, received, 22, TOP_BAR_HEIGHT + 204, screenW - 44, 178);
  }
  free(jpeg);
}

void fetchBrowserPage(const char *query) {
  browserQuery = query; browserStatus = "Searching..."; browserText = ""; browserOverview = "Collecting public search results."; renderBrowserCache(); showApp();
  beginWiFiOnBoot(); uint32_t started = millis(); while (WiFi.status() != WL_CONNECTED && millis() - started < 12000) { delay(80); M5.update(); }
  if (WiFi.status() != WL_CONNECTED) { browserStatus = "Wi-Fi connection failed"; browserText = "The saved network did not connect. Check its name and password, then search again."; renderBrowserCache(); showApp(); return; }
  String encoded = browserQuery; encoded.replace(" ", "+"); browserUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
  HTTPClient http; WiFiClientSecure client; client.setInsecure();
  if (!http.begin(client, browserUrl)) { browserStatus = "Could not start search"; renderBrowserCache(); showApp(); return; }
  http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setTimeout(12000); int status = http.GET();
  if (status < 200 || status >= 400) { browserStatus = status <= 0 ? "Search request failed" : "Search returned " + String(status); browserText = "Try another topic after checking Wi-Fi."; http.end(); renderBrowserCache(); showApp(); return; }
  String html = http.getString(); http.end(); if (html.length() > 10000) html.remove(10000);
  String plain; plain.reserve(html.length()); bool tag = false, space = false;
  for (size_t i = 0; i < html.length(); ++i) { char c = html[i]; if (c == '<') { tag = true; space = true; continue; } if (c == '>') { tag = false; continue; } if (!tag) { if (c == '\n' || c == '\r' || c == '\t') { space = true; continue; } if (space && plain.length()) plain += ' '; space = false; if ((uint8_t)c >= 32) plain += c; } }
  plain.replace("&nbsp;", " "); plain.replace("&amp;", "&"); plain.replace("&quot;", "\""); browserText = plain.length() ? plain : "No readable result text was returned.";
  String shortText = browserText.substring(0, min((int)browserText.length(), 260)); shortText.trim(); browserOverview = "For " + browserQuery + ", the result page begins: " + shortText;
  String imageTag = browserQuery; imageTag.toLowerCase(); imageTag.replace(" ", ","); browserImageUrl = "https://loremflickr.com/640/320/" + imageTag;
  browserStatus = "Results loaded - related image below"; renderBrowserCache(); showApp(); drawBrowserImage();
}

void renderFilesCache() {
  LGFX_Sprite &s = appScreens[2]; s.fillSprite(UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK); s.drawString("Files", 22, TOP_BAR_HEIGHT + 16);
  setSystemFont(s, 16); s.setTextColor(0xC618, UI_BLACK); s.drawString(filesUseSd ? "Storage: SD card" : "Storage: Flash", 22, TOP_BAR_HEIGHT + 62);
  // Regular actions use the compact FluxOS blue capsule style: a soft rounded
  // blue button with a small white confirmation mark, like the supplied example.
  s.fillRoundRect(screenW - 178, TOP_BAR_HEIGHT + 42, 156, 42, 21, UI_BLUE);
  s.fillCircle(screenW - 158, TOP_BAR_HEIGHT + 63, 11, UI_WHITE);
  s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 16); s.setTextColor(UI_BLUE, UI_WHITE);
  s.drawString("✓", screenW - 158, TOP_BAR_HEIGHT + 63);
  s.setTextDatum(textdatum_t::middle_left); s.setTextColor(UI_WHITE, UI_BLUE);
  s.drawString("Switch storage", screenW - 140, TOP_BAR_HEIGHT + 63);
  int y = TOP_BAR_HEIGHT + 108; s.fillRoundRect(18, y, screenW - 36, 64, 12, CARD);
  int actionW = (screenW - 62) / 2;
  s.fillRoundRect(26, y + 10, actionW, 44, 22, UI_BLUE);
  s.fillRoundRect(36 + actionW, y + 10, actionW, 44, 22, UI_BLUE);
  s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 16); s.setTextColor(UI_WHITE, UI_BLUE);
  s.drawString("+ New file", 26 + actionW / 2, y + 32);
  s.drawString("+ New folder", 36 + actionW + actionW / 2, y + 32);
  y += 86; s.fillRoundRect(18, y, screenW - 36, 90, 12, CARD); s.setTextColor(UI_WHITE, CARD); setSystemFont(s, 26); s.drawString(filesHasExport ? "freeform-drawing.ppm" : "No exported drawings yet", 38, y + 18); setSystemFont(s, 16); s.setTextColor(0xC618, CARD); s.drawString(filesHasExport ? "Tap to view the drawing" : "Export a drawing from Freeform to see it here", 38, y + 56);
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

// Freeform exports a binary PPM (P6), so Files decodes it directly without an
// additional image library. The preview is rendered once into the existing RAM
// screen; no repeated SD or Flash reads occur while it is visible.
void renderFilesImageViewer() {
  LGFX_Sprite &s = appScreens[2];
  s.fillSprite(UI_BLACK);
  s.setTextDatum(textdatum_t::top_left); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK);
  s.drawString("‹ Files", 22, TOP_BAR_HEIGHT + 16);
  setSystemFont(s, 16); s.setTextColor(0xC618, UI_BLACK); s.drawString("freeform-drawing.ppm", 22, TOP_BAR_HEIGHT + 56);

  fs::FS *store = nullptr;
  if (filesUseSd && SD_MMC.cardType() != CARD_NONE && SD_MMC.exists("/freeform-drawing.ppm")) store = static_cast<fs::FS *>(&SD_MMC);
  else if (SPIFFS.exists("/freeform-drawing.ppm")) store = static_cast<fs::FS *>(&SPIFFS);
  if (!store) {
    s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK);
    s.drawString("Drawing not found", screenW / 2, screenH / 2);
    drawGestureBar(s); drawTopBar(s, UI_BLACK); return;
  }

  File f = store->open("/freeform-drawing.ppm", FILE_READ);
  char line[40] = {}; int imageW = 0, imageH = 0, maxValue = 0;
  bool valid = f && f.readBytesUntil('\n', line, sizeof(line) - 1) > 0 && strcmp(line, "P6") == 0;
  if (valid) { memset(line, 0, sizeof(line)); f.readBytesUntil('\n', line, sizeof(line) - 1); valid = sscanf(line, "%d %d", &imageW, &imageH) == 2; }
  if (valid) { memset(line, 0, sizeof(line)); f.readBytesUntil('\n', line, sizeof(line) - 1); valid = sscanf(line, "%d", &maxValue) == 1 && maxValue == 255; }
  if (!valid || imageW < 1 || imageH < 1) {
    if (f) f.close(); s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK);
    s.drawString("Drawing could not be opened", screenW / 2, screenH / 2); drawGestureBar(s); drawTopBar(s, UI_BLACK); return;
  }

  const int top = TOP_BAR_HEIGHT + 92, margin = 20;
  int scale = max(1, min((screenW - margin * 2) / imageW, (screenH - top - 86) / imageH));
  int drawW = imageW * scale, drawH = imageH * scale, left = (screenW - drawW) / 2, drawTop = top + max(0, (screenH - top - 72 - drawH) / 2);
  s.fillRoundRect(left - 3, drawTop - 3, drawW + 6, drawH + 6, 8, 0x7BEF);
  uint8_t rgb[3];
  for (int py = 0; py < imageH; ++py) for (int px = 0; px < imageW; ++px) {
    if (f.read(rgb, 3) != 3) { valid = false; break; }
    uint16_t color = ((rgb[0] >> 3) << 11) | ((rgb[1] >> 2) << 5) | (rgb[2] >> 3);
    s.fillRect(left + px * scale, drawTop + py * scale, scale, scale, color);
  }
  f.close();
  if (!valid) { s.fillRect(0, top, screenW, screenH - top, UI_BLACK); s.setTextDatum(textdatum_t::middle_center); setSystemFont(s, 26); s.setTextColor(UI_WHITE, UI_BLACK); s.drawString("Drawing could not be opened", screenW / 2, screenH / 2); }
  drawGestureBar(s); drawTopBar(s, UI_BLACK);
}

void drawFreeformHoldMenu() {
  // Grouped light-gray action sheet inspired by the supplied reference. The
  // red first row is intentionally reserved for the destructive clear action.
  const int w = min(screenW - 64, 430), h = 292, x = (screenW - w) / 2, y = (screenH - h) / 2;
  const uint16_t sheet = 0xD69A, divider = 0xAD55, rowText = 0x2104;
  M5.Display.fillRoundRect(x - 4, y - 4, w + 8, h + 8, 26, UI_BLACK);
  M5.Display.fillRoundRect(x, y, w, h, 24, sheet);

  auto actionRow = [&](int rowY, const char *label, uint16_t colour, bool arrow) {
    M5.Display.drawRoundRect(x + 22, rowY + 13, 16, 16, 3, colour);
    M5.Display.setTextDatum(textdatum_t::middle_left); setSystemFont(M5.Display, 16);
    M5.Display.setTextColor(colour, sheet); M5.Display.drawString(label, x + 58, rowY + 21);
    if (arrow) {
      M5.Display.setTextDatum(textdatum_t::middle_right);
      M5.Display.drawString(">", x + w - 28, rowY + 21);
    }
  };

  M5.Display.setTextDatum(textdatum_t::top_left); setSystemFont(M5.Display, 16);
  M5.Display.setTextColor(0x7BEF, sheet); M5.Display.drawString("DRAWING", x + 22, y + 18);
  actionRow(y + 43, "Delete", UI_RED, false);
  M5.Display.drawFastHLine(x + 22, y + 86, w - 44, divider);

  M5.Display.setTextColor(0x7BEF, sheet); M5.Display.drawString("APPEARANCE & STORAGE", x + 22, y + 103);
  actionRow(y + 128, "Set as background", rowText, true);
  M5.Display.drawFastHLine(x + 22, y + 171, w - 44, divider);
  actionRow(y + 178, "Move to SD/Flash", rowText, true);
  M5.Display.drawFastHLine(x + 22, y + 221, w - 44, divider);

  M5.Display.setTextDatum(textdatum_t::top_left); setSystemFont(M5.Display, 16);
  M5.Display.setTextColor(0x7BEF, sheet);
  M5.Display.drawString("Press outside this panel to cancel", x + 22, y + 247);
}

void setFreeformAsBackground() {
  const int paperX = 18, paperY = TOP_BAR_HEIGHT + 152, paperW = screenW - 36, paperH = screenH - 76 - paperY;
  uint16_t *destination = static_cast<uint16_t *>(wallpaperCache.getBuffer());
  if (!destination) return;
  for (int y = 0; y < screenH; ++y) for (int x = 0; x < screenW; ++x) {
    int sourceX = paperX + x * paperW / screenW;
    int sourceY = paperY + y * paperH / screenH;
    destination[y * screenW + x] = appScreens[0].readPixel(sourceX, sourceY);
  }
  drawHomeContents(homeScreen); renderLockCache();
}

void exportFreeformToFiles() {
  const int paperX = 18, paperY = TOP_BAR_HEIGHT + 152, paperW = screenW - 36, paperH = screenH - 76 - paperY;
  // A full portrait PPM is about 2 MB. Export at half resolution so the same
  // drawing also fits the Tab5's internal Flash when no microSD card is fitted.
  const int w = (paperW + 1) / 2, h = (paperH + 1) / 2;
  fs::FS *store = filesUseSd && SD_MMC.cardType() != CARD_NONE ? static_cast<fs::FS *>(&SD_MMC) : static_cast<fs::FS *>(&SPIFFS);
  const char *path = "/freeform-drawing.ppm";
  filesHasExport = false;
  File f = store->open(path, FILE_WRITE);
  if (!f && store != static_cast<fs::FS *>(&SPIFFS)) {
    store = static_cast<fs::FS *>(&SPIFFS);
    f = store->open(path, FILE_WRITE);
    filesUseSd = false;
  }
  if (!f) return;
  bool complete = f.printf("P6\n%d %d\n255\n", w, h) > 0;
  for (int oy = 0; oy < h && complete; ++oy) {
    for (int ox = 0; ox < w; ++ox) {
      uint16_t p = appScreens[0].readPixel(paperX + ox * 2, paperY + oy * 2);
      uint8_t rgb[3] = {(uint8_t)(((p >> 11) & 31) * 255 / 31), (uint8_t)(((p >> 5) & 63) * 255 / 63), (uint8_t)((p & 31) * 255 / 31)};
      if (f.write(rgb, sizeof(rgb)) != sizeof(rgb)) { complete = false; break; }
    }
  }
  f.close();
  if (!complete) { store->remove(path); return; }
  filesHasExport = true;
}

void renderAppCache(int i) {
  if (i == 0) { renderFreeformCache(); return; }
  if (i == 1) { renderBrowserCache(); return; }
  if (i == 2) { renderFilesCache(); return; }
  if (i == VOICE_MEMOS_APP) { renderVoiceMemosCache(); return; }
  if (i == GITCODE_APP) { renderGitCodeCache(); return; }
  appScreens[i].fillSprite(UI_BLACK);
  drawIcon(appScreens[i], appIconX, appIconY, appIconSize, APP_COLORS[i], i == SETTINGS_APP);
  appScreens[i].setTextDatum(textdatum_t::top_center); setSystemFont(appScreens[i], 48); appScreens[i].setTextColor(UI_WHITE, UI_BLACK);
  appScreens[i].drawString(APP_NAMES[i], screenW / 2, appIconY + appIconSize + max(40, screenH / 20));
  drawGestureBar(appScreens[i]);
  drawTopBar(appScreens[i], UI_BLACK);
}

bool chargerConnected() {
  // M5Unified exposes the Tab5 charging state through its power manager.
  return M5.Power.isCharging();
}

void drawControlCenter() {
  // Keep this modal independent of the SD-backed font and status-bar refresh
  // paths. A control-center pull must remain safe even if the card is removed
  // or a font file is temporarily unavailable.
  M5.Display.fillScreen(UI_BLACK);
  const int panelX = 18, panelY = TOP_BAR_HEIGHT + 24, panelW = screenW - 36;
  const int panelH = min(screenH - panelY - 48, 430);
  const bool wifiConnected = WiFi.status() == WL_CONNECTED;
  const int battery = shownBattery;
  M5.Display.fillRoundRect(panelX, panelY, panelW, panelH, 26, CARD);
  M5.Display.setFont(&fonts::Font4);
  M5.Display.setTextDatum(textdatum_t::top_left);
  M5.Display.setTextColor(UI_WHITE, CARD); M5.Display.drawString("Control Center", panelX + 24, panelY + 20);
  M5.Display.setFont(&fonts::Font2);
  M5.Display.setTextColor(0xC618, CARD); M5.Display.drawString("Lift your finger to close", panelX + 24, panelY + 58);
  const int tileY = panelY + 98, gap = 14, tileW = (panelW - 48 - gap) / 2, tileH = 94;
  M5.Display.fillRoundRect(panelX + 24, tileY, tileW, tileH, 18, wifiConnected ? UI_BLUE : 0x4208);
  M5.Display.fillRoundRect(panelX + 24 + tileW + gap, tileY, tileW, tileH, 18, 0x4208);
  M5.Display.setTextDatum(textdatum_t::middle_center); M5.Display.setFont(&fonts::Font4);
  M5.Display.setTextColor(UI_WHITE); M5.Display.drawString("Wi-Fi", panelX + 24 + tileW / 2, tileY + 34);
  M5.Display.drawString("Battery", panelX + 24 + tileW + gap + tileW / 2, tileY + 34);
  M5.Display.setFont(&fonts::Font2); M5.Display.setTextColor(0xE71C);
  M5.Display.drawString(wifiConnected ? "Connected" : "Offline", panelX + 24 + tileW / 2, tileY + 64);
  char percent[12];
  if (battery >= 0 && battery <= 100) snprintf(percent, sizeof(percent), "%d%%", battery);
  else snprintf(percent, sizeof(percent), "--");
  M5.Display.drawString(percent, panelX + 24 + tileW + gap + tileW / 2, tileY + 64);
  const int rowY = tileY + tileH + 22;
  M5.Display.fillRoundRect(panelX + 24, rowY, panelW - 48, 62, 18, UI_BLACK);
  M5.Display.setTextDatum(textdatum_t::middle_left); M5.Display.setTextColor(UI_WHITE, UI_BLACK);
  M5.Display.drawString("Flux app protection", panelX + 44, rowY + 22);
  M5.Display.setTextColor(0xC618, UI_BLACK); M5.Display.drawString("GitCode packages use restricted FluxUI declarations", panelX + 44, rowY + 43);
}

void drawScreensaver() {
  M5.Display.fillScreen(UI_BLACK);
  auto dt = M5.Rtc.getDateTime();
  char clockText[8];
  snprintf(clockText, sizeof(clockText), "%02d:%02d", dt.time.hours, dt.time.minutes);
  M5.Display.setTextDatum(textdatum_t::middle_center);
  setSystemFont(M5.Display, 48); M5.Display.setTextColor(0xC618, UI_BLACK);
  M5.Display.drawString(clockText, screenW / 2, screenH / 2 - 16);
  setSystemFont(M5.Display, 16); M5.Display.setTextColor(0x7BEF, UI_BLACK);
  M5.Display.drawString("Tap to wake", screenW / 2, screenH / 2 + 48);
}

void renderSettingsCache() {
  settingsScreen.fillSprite(UI_BLACK);
  settingsScreen.setTextColor(UI_WHITE, UI_BLACK); settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 48);
  settingsScreen.drawString("Settings", max(24, screenW / 14), max(30, screenH / 14));
  int x = max(20, screenW / 15), y = 96, w = screenW - x * 2, h = 92;
  settingsScreen.fillRoundRect(x, y, w, h, 18, CARD);
  setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("System", x + 22, y + 18);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Recovery and device information", x + 22, y + 56);
  int toggleY = y + h + 16, toggleH = 74;
  settingsScreen.fillRoundRect(x, toggleY, w, toggleH, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Boot animation", x + 22, toggleY + 15);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Show the FluxOS startup reveal", x + 22, toggleY + 49);
  drawTogglePixels(settingsScreen, x + w - TOGGLE_ON_WIDTH - 18, toggleY + (toggleH - TOGGLE_ON_HEIGHT) / 2, bootAnimationEnabled ? TOGGLE_ON_DATA : TOGGLE_OFF_DATA);
  int bootModeY = toggleY + toggleH + 14, bootModeH = 74;
  settingsScreen.fillRoundRect(x, bootModeY, w, bootModeH, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Boot screen", x + 22, bootModeY + 15);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString(logoBootEnabled ? "FluxOS logo" : "Terminal startup", x + 22, bootModeY + 49);
  drawTogglePixels(settingsScreen, x + w - TOGGLE_ON_WIDTH - 18, bootModeY + (bootModeH - TOGGLE_ON_HEIGHT) / 2, logoBootEnabled ? TOGGLE_ON_DATA : TOGGLE_OFF_DATA);
  int saverY = bootModeY + bootModeH + 14;
  settingsScreen.fillRoundRect(x, saverY, w, 74, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Screensaver", x + 22, saverY + 11);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Show a quiet clock after 1 minute", x + 22, saverY + 43);
  drawTogglePixels(settingsScreen, x + w - TOGGLE_ON_WIDTH - 18, saverY + (74 - TOGGLE_ON_HEIGHT) / 2, screensaverEnabled ? TOGGLE_ON_DATA : TOGGLE_OFF_DATA);

  int timeoutY = saverY + 88;
  settingsScreen.fillRoundRect(x, timeoutY, w, 74, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Screen timeout", x + 22, timeoutY + 11);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Turn the display off after 2 minutes", x + 22, timeoutY + 43);
  drawTogglePixels(settingsScreen, x + w - TOGGLE_ON_WIDTH - 18, timeoutY + (74 - TOGGLE_ON_HEIGHT) / 2, screenTimeoutEnabled ? TOGGLE_ON_DATA : TOGGLE_OFF_DATA);

  int chargingY = timeoutY + 88;
  settingsScreen.fillRoundRect(x, chargingY, w, 74, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Stay awake while charging", x + 22, chargingY + 11);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Do not time out when USB power is charging", x + 22, chargingY + 43);
  drawTogglePixels(settingsScreen, x + w - TOGGLE_ON_WIDTH - 18, chargingY + (74 - TOGGLE_ON_HEIGHT) / 2, keepAwakeWhileCharging ? TOGGLE_ON_DATA : TOGGLE_OFF_DATA);

  int gitCodeY = chargingY + 88, gitCodeH = 58;
  settingsScreen.fillRoundRect(x, gitCodeY, w, gitCodeH, 16, UI_BLUE);
  settingsScreen.setTextDatum(textdatum_t::middle_center); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, UI_BLUE);
  settingsScreen.drawString(gitcodeInstalled ? "GitCode Preview installed" : "Download GitCode Preview", screenW / 2, gitCodeY + gitCodeH / 2);
  int resetY = gitCodeY + gitCodeH + 14, resetH = 58;
  settingsScreen.fillRoundRect(x, resetY, w, resetH, 16, UI_RED);
  settingsScreen.setTextDatum(textdatum_t::middle_center); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, UI_RED);
  settingsScreen.drawString("Factory reset", screenW / 2, resetY + resetH / 2);
  int creditsY = resetY + resetH + 16, creditsH = 100;
  settingsScreen.fillRoundRect(x, creditsY, w, creditsH, 18, CARD);
  settingsScreen.setTextDatum(textdatum_t::top_left); setSystemFont(settingsScreen, 26); settingsScreen.setTextColor(UI_WHITE, CARD);
  settingsScreen.drawString("Credits", x + 22, creditsY + 16);
  setSystemFont(settingsScreen, 16); settingsScreen.setTextColor(0xC618, CARD);
  settingsScreen.drawString("Icons by icons8.com", x + 22, creditsY + 54);
  settingsScreen.drawString("Made with Schematik", x + 22, creditsY + 78);
  drawGestureBar(settingsScreen);
  drawTopBar(settingsScreen, UI_BLACK);
}

void renderLockCache() {
  drawWallpaper(lockScreen);
  lockScreen.fillRect(0, 0, screenW, TOP_BAR_HEIGHT, UI_BLACK);
  lockScreen.setTextDatum(textdatum_t::middle_center); setSystemFont(lockScreen, 48); lockScreen.setTextColor(UI_WHITE);
  auto dt = M5.Rtc.getDateTime(); char clockText[8]; snprintf(clockText, sizeof(clockText), "%02d:%02d", dt.time.hours, dt.time.minutes);
  lockScreen.drawString(clockText, screenW / 2, screenH / 2 - 36);
  setSystemFont(lockScreen, 26); lockScreen.drawString("FluxOS Slethal v2", screenW / 2, screenH / 2 + 26);
  setSystemFont(lockScreen, 16); lockScreen.drawString("Tap anywhere to unlock", screenW / 2, screenH - max(90, screenH / 10));
}
void showLock() { lockScreen.pushSprite(0, 0); }
void showHome() { homeScreen.pushSprite(0, 0); refreshTopBar(millis(), true); }
void showApp() { appScreens[selectedApp].pushSprite(0, 0); refreshTopBar(millis(), true); }
void showSettings() { settingsScreen.pushSprite(0, 0); refreshTopBar(millis(), true); }

bool touchPoint(int &x, int &y) {
  // Tab5 touch events are stateful. A fresh tap can be reported as
  // wasPressed before it appears in the active-point count, so use the
  // documented touch detail state rather than getCount().
  auto p = M5.Touch.getDetail();
  if (!(p.isPressed() || p.wasPressed() || p.isHolding() || p.wasHold())) return false;
  x = p.x;
  y = p.y;
  return true;
}

void refreshGestureBar(uint32_t now) {
  if (!gestureTappedAt || now - gestureTappedAt > GESTURE_HOLD_MS + GESTURE_FADE_MS) return;
  int w = max(96, screenW / 5), h = max(6, screenH / 160), resting = screenH - max(42, screenH / 24);
  M5.Display.fillRect((screenW - w) / 2 - 2, resting - max(10, screenH / 50), w + 4, h + max(14, screenH / 35), UI_BLACK);
  M5.Display.fillRoundRect((screenW - w) / 2, gestureBarY(now), w, h, h / 2, gestureBarColor(now));
}

void hideGestureBar() {
  int w = max(96, screenW / 5), h = max(6, screenH / 160), resting = screenH - max(42, screenH / 24);
  M5.Display.fillRect((screenW - w) / 2 - 2, resting - max(10, screenH / 50), w + 4, h + max(14, screenH / 35), HOME_BG);
}

void drawOpeningFrame(float progress) {
  float eased = 0.5f - 0.5f * cosf(progress * PI);
  homeScreen.pushSprite(0, 0);
  float cx = homeIconX[selectedApp] + iconSize * 0.5f, cy = homeIconY[selectedApp] + iconSize * 0.5f;
  int size = iconSize + (int)((max(screenW, screenH) * 2 - iconSize) * eased);
  M5.Display.fillRoundRect((int)(cx - size * .5f), (int)(cy - size * .5f), size, size, max(8, size / 4), APP_COLORS[selectedApp]);
}

// Draw the switcher entirely in the hidden RAM sprite. The simple gray tiles
// are a deliberate local blur approximation: as the pull increases, detail is
// restored in the rectangle surrounding the card rather than repainting the LCD.
void drawSwitcherBackground(float clarity) {
  clarity = constrain(clarity, 0.0f, 1.0f);
  // Keep the live gesture path deliberately simple. Re-decoding or copying a
  // full SD wallpaper here made finger tracking visibly laggy.
  uint16_t fog = blend565(HOME_BG, CARD, 0.42f);
  switcherBuffer.fillSprite(fog);
  switcherBuffer.setTextDatum(textdatum_t::top_center);
  for (int i = 0; i < APP_COUNT; ++i) {
    uint16_t tile = blend565(fog, APP_COLORS[i], 0.22f + clarity * 0.78f);
    switcherBuffer.fillRoundRect(homeIconX[i], homeIconY[i], iconSize, iconSize, max(8, iconSize / 4), tile);
  }
  int barW = max(96, screenW / 5), barH = max(6, screenH / 160);
  switcherBuffer.fillRect((screenW - barW) / 2 - 4, screenH - max(58, screenH / 16), barW + 8, barH + 20, fog);
}

void drawFloatingCard(float cx, float cy, float scale) {
  int icon = max(28, (int)(appIconSize * scale)), gap = max(12, (int)(screenH / 20 * scale));
  int pw = min(screenW - 20, icon + max(56, (int)(screenW / 8 * scale))), ph = icon + gap + max(42, (int)(screenH / 12 * scale));
  int px = constrain((int)cx - pw / 2, 10, screenW - pw - 10), py = constrain((int)cy - ph / 2, 10, screenH - ph - 10);
  float pulled = constrain(1.0f - scale, 0.0f, 1.0f);

  // First finish the complete frame off-screen. Only its changed rectangle is
  // transferred after all background, icon, and text pixels are in place.
  drawSwitcherBackground(pulled * 1.65f);
  switcherBuffer.fillRoundRect(px, py, pw, ph, max(12, icon / 5), CARD);
  int iconX = px + (pw - icon) / 2, iconY = py + max(14, ph / 10);
  const uint16_t *artwork = selectedApp == 0 ? MAIL_ICON_DATA : selectedApp == 1 ? BROWSER_ICON_DATA : selectedApp == 2 ? PUSH_ICON_DATA : selectedApp == SETTINGS_APP ? SETTINGS_ICON_DATA : selectedApp == VOICE_MEMOS_APP ? VOICE_MEMOS_ICON_DATA : GITCODE_PREVIEW_ICON_DATA;
  for (int row = 0; row < icon; ++row) for (int col = 0; col < icon; ++col) {
    uint16_t pixel = pgm_read_word(artwork + (row * 96 / icon) * 96 + (col * 96 / icon));
    if (pixel != 0x0000) switcherBuffer.drawPixel(iconX + col, iconY + row, tab5Color(pixel));
  }
  switcherBuffer.setTextDatum(textdatum_t::top_center); switcherBuffer.setTextColor(UI_WHITE, CARD); setSystemFont(switcherBuffer, scale > .62f ? 26 : 16);
  switcherBuffer.drawString(APP_NAMES[selectedApp], px + pw / 2, py + max(14, ph / 10) + icon + gap / 2);

  // Include the old card in the dirty rectangle so its previous pixels are
  // replaced, while untouched parts of the display are never transferred.
  int pad = max(18, screenH / 30);
  int left = px - pad, top = py - pad, right = px + pw + pad, bottom = py + ph + pad;
  if (hasOldPanel) { left = min(left, oldPanelX - pad); top = min(top, oldPanelY - pad); right = max(right, oldPanelX + oldPanelW + pad); bottom = max(bottom, oldPanelY + oldPanelH + pad); }
  left = constrain(left, 0, screenW); top = constrain(top, 0, screenH); right = constrain(right, left, screenW); bottom = constrain(bottom, top, screenH);
  // M5GFX does not provide a cropped pushSprite overload. Send only the
  // finished dirty rectangle from the hidden RGB565 buffer, one contiguous
  // row at a time, so unchanged screen areas are not transferred.
  const uint16_t *frame = static_cast<const uint16_t *>(switcherBuffer.getBuffer());
  int dirtyW = right - left;
  for (int row = top; row < bottom; ++row) {
    M5.Display.pushImage(left, row, dirtyW, 1, frame + row * screenW + left);
  }
  oldPanelX = px; oldPanelY = py; oldPanelW = pw; oldPanelH = ph; hasOldPanel = true;
}

void terminalLine(int row, const char *tag, uint16_t color, const char *message) {
  int y = 32 + row * max(29, screenH / 18);
  M5.Display.setTextDatum(textdatum_t::top_left); setSystemFont(M5.Display, 16); M5.Display.setTextColor(color, UI_BLACK); M5.Display.drawString(tag, 20, y);
  M5.Display.setTextColor(UI_WHITE, UI_BLACK); M5.Display.drawString(message, 88, y);
}

void drawBootTerminal(uint32_t elapsed) {
  bootBuffer.fillSprite(UI_BLACK);
  bootBuffer.setTextDatum(textdatum_t::top_left); setSystemFont(bootBuffer, 26); bootBuffer.setTextColor(UI_WHITE, UI_BLACK);
  bootBuffer.drawString("FluxOS Slethal v2", 20, 12);
  auto line = [&](int row, const char *tag, uint16_t color, const char *message) {
    int y = 32 + row * max(29, screenH / 18);
    setSystemFont(bootBuffer, 16); bootBuffer.setTextColor(color, UI_BLACK); bootBuffer.drawString(tag, 20, y);
    bootBuffer.setTextColor(UI_WHITE, UI_BLACK); bootBuffer.drawString(message, 88, y);
  };
  const char *loading = "[***]";
  if (bootCacheStage >= 1) line(0, "[OK]", UI_GREEN, "Home UI rendered in RAM"); else line(0, loading, UI_RED, "rendering Home UI in RAM");
  if (bootCacheStage >= 2) line(1, "[OK]", UI_GREEN, "Mail app rendered in RAM"); else if (bootCacheStage >= 1) line(1, loading, UI_RED, "rendering Mail app in RAM");
  if (bootCacheStage >= 6) line(2, "[OK]", UI_GREEN, "all app views rendered in RAM"); else if (bootCacheStage >= 2) line(2, loading, UI_RED, "rendering remaining apps in RAM");
  if (bootCacheStage >= 7) line(3, "[OK]", UI_GREEN, "Settings rendered; launcher ready"); else if (bootCacheStage >= 6) line(3, loading, UI_RED, "rendering Settings in RAM");
  bootBuffer.pushSprite(0, 0);
}

void drawBootLogo() {
  // Keep the supplied logo proportional. It is centred on black rather than
  // stretched independently in each direction to fill the Tab5 portrait panel.
  bootBuffer.fillSprite(UI_BLACK);
  const float scale = min(screenW / (float)FLUXOS_BOOT_LOGO_WIDTH, screenH / (float)FLUXOS_BOOT_LOGO_HEIGHT);
  const int drawW = max(1, (int)(FLUXOS_BOOT_LOGO_WIDTH * scale));
  const int drawH = max(1, (int)(FLUXOS_BOOT_LOGO_HEIGHT * scale));
  const int left = (screenW - drawW) / 2;
  const int top = (screenH - drawH) / 2;
  for (int y = 0; y < drawH; ++y) {
    const int sy = y * FLUXOS_BOOT_LOGO_HEIGHT / drawH;
    for (int x = 0; x < drawW; ++x) {
      const int sx = x * FLUXOS_BOOT_LOGO_WIDTH / drawW;
      bootBuffer.drawPixel(left + x, top + y, pgm_read_word(FLUXOS_BOOT_LOGO_DATA + sy * FLUXOS_BOOT_LOGO_WIDTH + sx));
    }
  }
  bootBuffer.pushSprite(0, 0);
}

void drawBootReveal(float progress) {
  // Assemble the entire transition off-screen, then transfer the completed frame.
  float eased = 0.5f - 0.5f * cosf(constrain(progress, 0.0f, 1.0f) * PI);
  int x = (int)((1.0f - eased) * screenW);
  bootBuffer.fillSprite(UI_BLACK);
  homeScreen.pushSprite(&bootBuffer, x, 0);
  bootBuffer.pushSprite(0, 0);
}

void drawConfirmation() {
  M5.Display.fillScreen(UI_BLACK);
  int x = max(20, screenW / 12), w = screenW - x * 2, y = max(60, screenH / 6);
  M5.Display.fillRoundRect(x, y, w, screenH - y * 2, 20, CARD);
  M5.Display.setTextDatum(textdatum_t::top_center); M5.Display.setTextColor(UI_WHITE, CARD); setSystemFont(M5.Display, 48);
  M5.Display.drawString("Factory reset?", screenW / 2, y + 26);
  setSystemFont(M5.Display, 16); M5.Display.setTextColor(0xC618, CARD);
  M5.Display.drawString("This restarts FluxOS and clears", screenW / 2, y + 88);
  M5.Display.drawString("its temporary session.", screenW / 2, y + 112);
  int bh = max(50, screenH / 12), by = screenH - y - bh - 16;
  M5.Display.fillRoundRect(x + 14, by, (w - 42) / 2, bh, 14, 0x4208);
  M5.Display.fillRoundRect(x + (w + 14) / 2, by, (w - 42) / 2, bh, 14, UI_RED);
  setSystemFont(M5.Display, 16); M5.Display.setTextColor(UI_WHITE);
  M5.Display.drawString("Show terminal", x + 14 + (w - 42) / 4, by + bh / 2 - 7);
  M5.Display.drawString("Reset", x + (w + 14) / 2 + (w - 42) / 4, by + bh / 2 - 7);
}

void drawResetting(uint32_t elapsed) {
  M5.Display.fillScreen(UI_BLACK);
  if (!showResetTerminal) {
    M5.Display.setTextDatum(textdatum_t::middle_center); setSystemFont(M5.Display, 48); M5.Display.setTextColor(UI_WHITE, UI_BLACK);
    M5.Display.drawString("Resetting...", screenW / 2, screenH / 2);
    return;
  }
  M5.Display.setTextDatum(textdatum_t::top_left); setSystemFont(M5.Display, 26); M5.Display.setTextColor(UI_WHITE, UI_BLACK);
  M5.Display.drawString("FluxOS reset terminal", 20, 12);
  terminalLine(0, elapsed > 250 ? "[OK]" : "[***]", elapsed > 250 ? UI_GREEN : UI_RED, elapsed > 250 ? "reset requested" : "clearing session");
  terminalLine(1, elapsed > 600 ? "[OK]" : "[***]", elapsed > 600 ? UI_GREEN : UI_RED, elapsed > 600 ? "launcher cache renewed" : "renewing launcher cache");
  terminalLine(2, elapsed > 1000 ? "[OK]" : "[***]", elapsed > 1000 ? UI_GREEN : UI_RED, elapsed > 1000 ? "restarting FluxOS" : "preparing restart");
}

void setup() {
  auto cfg = M5.config();
  // Tab5 Voice Memos needs both built-in audio paths enabled from startup.
  // They remain available as the app switches between capture and playback.
  cfg.internal_mic = true;
  cfg.internal_spk = true;
  M5.begin(cfg); M5.Display.setRotation(1);
  // Rotation 1 is the Tab5's 720 x 1280 portrait canvas used by FluxOS.
  screenW = M5.Display.width(); screenH = M5.Display.height();
  // The Tab5's built-in card slot uses the ESP32 SD_MMC interface. If the
  // card or image is absent, FluxOS safely keeps its plain gray background.
  bool sdReady = SD_MMC.begin("/sdcard", true);
  sdWallpaperAvailable = sdReady && SD_MMC.exists("/fluxos.png");
  sfProAvailable = sdReady && SD_MMC.exists("/SFPro16px.vlw") && SD_MMC.exists("/SFPro26px.vlw") && SD_MMC.exists("/SFPro48px.vlw");
  // Most labels choose a size through setSystemFont(). Set the display default
  // too, so every remaining app label uses SF Pro whenever the three supplied
  // VLW faces are present on the SD card.
  setSystemFont(M5.Display, 16);
  SPIFFS.begin(true);
  // Start associating with the saved network now, during the boot screen, so
  // Search is ready sooner when the launcher is unlocked.
  beginWiFiOnBoot();
  // Keep previously exported drawings visible after FluxOS restarts, whether
  // the last export went to the microSD card or internal Flash.
  filesUseSd = sdReady;
  filesHasExport = (sdReady && SD_MMC.exists("/freeform-drawing.ppm")) || SPIFFS.exists("/freeform-drawing.ppm");
  if (SPIFFS.exists(VOICE_PATH)) { File memo = SPIFFS.open(VOICE_PATH, FILE_READ); voiceHasMemo = memo && memo.size() >= sizeof(int16_t); if (memo) memo.close(); }
  preferences.begin("fluxos", false);
  bootAnimationEnabled = preferences.getBool("bootReveal", true);
  logoBootEnabled = preferences.getBool("logoBoot", true);
  screensaverEnabled = preferences.getBool("screensaver", true);
  screenTimeoutEnabled = preferences.getBool("screenTimeout", true);
  keepAwakeWhileCharging = preferences.getBool("awakeCharging", true);
  gitcodeInstalled = preferences.getBool("gitcode", false);
  loadGitCodeApp();
  int shortSide = min(screenW, screenH);
  // Four evenly spaced icons echo a phone launcher while leaving breathing
  // room for the wallpaper and the dock on the Tab5 portrait display.
  iconSize = constrain(shortSide / 7, 96, 112);
  int gridGap = max(20, (screenW - iconSize * APP_COUNT) / (APP_COUNT + 1));
  int gridY = TOP_BAR_HEIGHT + max(42, screenH / 18);
  for (int i = 0; i < APP_COUNT; ++i) {
    homeIconX[i] = gridGap + i * (iconSize + gridGap);
    homeIconY[i] = gridY;
  }
  dockIconSize = constrain(shortSide / 9, 68, 82);
  int dockGap = max(16, (screenW - dockIconSize * APP_COUNT) / (APP_COUNT + 1));
  dockIconY = screenH - dockIconSize - max(72, screenH / 16);
  for (int i = 0; i < APP_COUNT; ++i) dockIconX[i] = dockGap + i * (dockIconSize + dockGap);
  appIconSize = shortSide / 3; appIconX = (screenW - appIconSize) / 2; appIconY = screenH / 4;
  homeScreen.setColorDepth(16); homeScreen.setPsram(true); homeScreen.createSprite(screenW, screenH);
  bootBuffer.setColorDepth(16); bootBuffer.setPsram(true); bootBuffer.createSprite(screenW, screenH);
  settingsScreen.setColorDepth(16); settingsScreen.setPsram(true); settingsScreen.createSprite(screenW, screenH);
  lockScreen.setColorDepth(16); lockScreen.setPsram(true); lockScreen.createSprite(screenW, screenH);
  wallpaperCache.setColorDepth(16); wallpaperCache.setPsram(true); wallpaperCache.createSprite(screenW, screenH);
  prepareWallpaperCache();
  switcherBuffer.setColorDepth(16); switcherBuffer.setPsram(true); switcherBuffer.createSprite(screenW, screenH);
  for (int i = 0; i < APP_COUNT; ++i) { appScreens[i].setColorDepth(16); appScreens[i].setPsram(true); appScreens[i].createSprite(screenW, screenH); }
  // Drawing happens in the Boot state so the terminal accurately reports each
  // completed off-screen PSRAM cache rather than reporting a past operation.
  bootStartedAt = millis();
  lastUserActivityAt = bootStartedAt;
  if (logoBootEnabled) drawBootLogo(); else drawBootTerminal(0);
}

void loop() {
  M5.update(); uint32_t now = millis(); int x = 0, y = 0; bool touching = touchPoint(x, y);
  if (screenState == ScreenState::Boot) {
    // Build exactly one completed screen image per boot stage in hidden PSRAM.
    // The LCD receives only the terminal buffer until every cache is finished.
    uint32_t elapsed = now - bootStartedAt;
    int wantedStage = min(7, (int)(elapsed / BOOT_LINE_MS));
    if (bootCacheStage < wantedStage) {
      ++bootCacheStage;
      if (bootCacheStage == 1) drawHomeContents(homeScreen);
      else if (bootCacheStage >= 2 && bootCacheStage <= 6) renderAppCache(bootCacheStage - 2);
      else if (bootCacheStage == 7) renderSettingsCache();
      if (!logoBootEnabled) drawBootTerminal(elapsed);
    } else if (!logoBootEnabled && now - lastFrameAt >= 40) { lastFrameAt = now; drawBootTerminal(elapsed); }
    if (bootCacheStage >= 7 && elapsed > BOOT_LINE_MS * 8) { renderLockCache(); if (bootAnimationEnabled) { screenState = ScreenState::BootReveal; bootRevealStartedAt = now; lastFrameAt = now - FRAME_MS; } else { screenState = ScreenState::Lock; showLock(); } }
    return;
  }
  if (screenState == ScreenState::BootReveal) {
    if (now - lastFrameAt < FRAME_MS) return;
    lastFrameAt = now;
    float p = min(1.0f, (now - bootRevealStartedAt) / (float)BOOT_REVEAL_MS);
    drawBootReveal(p);
    if (p >= 1.0f) { screenState = ScreenState::Lock; showLock(); }
    return;
  }
  if (screenState == ScreenState::Resetting) { drawResetting(now - resetStartedAt); if (now - resetStartedAt > 1450) ESP.restart(); return; }
  if (screenState == ScreenState::ScreenOff) {
    if (touching) { M5.Display.wakeup(); screenState = ScreenState::Lock; lastUserActivityAt = now; showLock(); touchWasDown = true; }
    return;
  }
  if (screenState == ScreenState::Screensaver) {
    if (touching) { screenState = ScreenState::Lock; lastUserActivityAt = now; showLock(); touchWasDown = true; }
    return;
  }
  if (screenState == ScreenState::ControlCenter) {
    // A release after the down-swipe closes the modal. A tap outside its card
    // does the same, leaving the underlying app and its state untouched.
    if (!touching) {
      screenState = controlCenterReturn;
      if (screenState == ScreenState::Home) showHome();
      else if (screenState == ScreenState::Settings) showSettings();
      else showApp();
      touchWasDown = touching;
    }
    return;
  }
  if (screenState == ScreenState::Lock) {
    // Do not require a one-frame edge event here: on some Tab5 touch reports a
    // valid tap is already down when this loop observes it. Any live touch
    // deliberately unlocks the screen.
    if (touching) { screenState = ScreenState::Home; touchWasDown = true; showHome(); }
    else touchWasDown = false;
    return;
  }
  if (screenState == ScreenState::Opening) { if (now - lastFrameAt < FRAME_MS) return; lastFrameAt = now; float p = min(1.0f, (now - animationStartedAt) / (float)OPEN_DURATION_MS); drawOpeningFrame(p); if (p >= 1) { screenState = selectedApp == SETTINGS_APP ? ScreenState::Settings : ScreenState::App; if (screenState == ScreenState::Settings) showSettings(); else showApp(); } return; }
  bool pressed = touching && !touchWasDown; touchWasDown = touching;
  if (touching) lastUserActivityAt = now;
  // Pull down from the real top edge to open Control Center. It is deliberately
  // checked before app-specific touch handling, so every launcher and app
  // screen behaves the same way.
  if (pressed && y <= TOP_BAR_HEIGHT) { topEdgeTracking = true; topEdgeStartY = y; }
  if (topEdgeTracking) {
    if (!touching) { topEdgeTracking = false; }
    else if (y - topEdgeStartY >= 54) {
      topEdgeTracking = false;
      controlCenterReturn = screenState;
      drawControlCenter();
      screenState = ScreenState::ControlCenter;
      return;
    }
  }
  bool canIdle = screenState == ScreenState::Home || screenState == ScreenState::App || screenState == ScreenState::Settings;
  bool chargingExempt = keepAwakeWhileCharging && chargerConnected();
  if (canIdle && !chargingExempt) {
    uint32_t idleFor = now - lastUserActivityAt;
    if (screenTimeoutEnabled && idleFor >= SCREEN_TIMEOUT_MS) {
      M5.Display.sleep(); screenState = ScreenState::ScreenOff; return;
    }
    if (screensaverEnabled && idleFor >= SCREENSAVER_DELAY_MS) {
      screenState = ScreenState::Screensaver; drawScreensaver(); return;
    }
  }
  if (screenState == ScreenState::ConfirmReset) { if (!pressed) return; int margin = max(20, screenW / 12), w = screenW - margin * 2, bh = max(50, screenH / 12), by = screenH - max(60, screenH / 6) - bh - 16; if (y >= by && y <= by + bh) { if (x < screenW / 2) { showResetTerminal = true; drawConfirmation(); } else { screenState = ScreenState::Resetting; resetStartedAt = now; drawResetting(0); } } return; }
  if (screenState == ScreenState::Settings) {
    if (!pressed) return;
    const int top = 96, h = 92, toggleY = top + h + 16, toggleH = 74;
    const int bootModeY = toggleY + toggleH + 14;
    const int saverY = bootModeY + 74 + 14, timeoutY = saverY + 88, chargingY = timeoutY + 88;
    const int gitCodeY = chargingY + 88, buttonY = gitCodeY + 72;
    if (y >= toggleY && y <= toggleY + toggleH) { bootAnimationEnabled = !bootAnimationEnabled; preferences.putBool("bootReveal", bootAnimationEnabled); renderSettingsCache(); showSettings(); return; }
    if (y >= bootModeY && y <= bootModeY + 74) { logoBootEnabled = !logoBootEnabled; preferences.putBool("logoBoot", logoBootEnabled); renderSettingsCache(); showSettings(); return; }
    if (y >= saverY && y <= saverY + 74) { screensaverEnabled = !screensaverEnabled; preferences.putBool("screensaver", screensaverEnabled); renderSettingsCache(); showSettings(); return; }
    if (y >= timeoutY && y <= timeoutY + 74) { screenTimeoutEnabled = !screenTimeoutEnabled; preferences.putBool("screenTimeout", screenTimeoutEnabled); renderSettingsCache(); showSettings(); return; }
    if (y >= chargingY && y <= chargingY + 74) { keepAwakeWhileCharging = !keepAwakeWhileCharging; preferences.putBool("awakeCharging", keepAwakeWhileCharging); renderSettingsCache(); showSettings(); return; }
    if (y >= gitCodeY && y <= gitCodeY + 58) {
      gitcodeInstalled = true; preferences.putBool("gitcode", true);
      if (SD_MMC.cardType() != CARD_NONE && !SD_MMC.exists(GITCODE_LUA_PATH)) {
        File example = SD_MMC.open(GITCODE_LUA_PATH, FILE_WRITE);
        if (example) { example.print("-- FluxUI app source\nui.title(\\\"My Flux App\\\")\nui.color(\\\"#2D9D\\\")\nui.text(\\\"Hello from FluxUI\\\")\n"); example.close(); }
      }
      gitcodeStatus = SD_MMC.cardType() == CARD_NONE ? "Insert an SD card, then add gitcode.lua." : "Sample gitcode.lua is ready in the SD root.";
      renderSettingsCache(); showSettings(); return;
    }
    if (y >= buttonY && y <= buttonY + 58) { screenState = ScreenState::ConfirmReset; showResetTerminal = false; drawConfirmation(); return; }
    if (y >= screenH - max(120, screenH / 8)) { gestureTappedAt = now; screenState = ScreenState::GestureArmed; gestureStartY = gestureLastY = y; gestureStartedAt = now; refreshGestureBar(now); }
    return;
  }
  if (screenState == ScreenState::GestureArmed) { if (!touching) { bool close = gestureStartY - gestureLastY >= 12; screenState = close ? ScreenState::Home : (selectedApp == SETTINGS_APP ? ScreenState::Settings : ScreenState::App); if (screenState == ScreenState::Home) showHome(); else if (screenState == ScreenState::Settings) showSettings(); else showApp(); touchWasDown = false; return; } gestureLastY = y;   if (gestureStartY - y >= 12) { screenState = ScreenState::Gesture; gestureTappedAt = 0; showHome(); hideGestureBar(); floatingCX = screenW * .5f; floatingCY = appIconY + appIconSize * .5f; floatingScale = 1; hasOldPanel = false; drawFloatingCard(floatingCX, floatingCY, floatingScale); lastFrameAt = now; } else refreshGestureBar(now); return; }
  if (screenState == ScreenState::Gesture) { if (!touching) { hasOldPanel = false; screenState = ScreenState::Home; showHome(); touchWasDown = false; return; } // The Tab5 touch panel can report a few pixels above the drawable area at its top edge. Hold the switcher at a safe inset instead of feeding that value into the card geometry.
    const int safeTop = 24;
    int fingerX = constrain(x, 0, screenW - 1);
    int fingerY = constrain(y, safeTop, screenH - 1);
    gestureLastY = fingerY; if (now - lastFrameAt < FRAME_MS) return; lastFrameAt = now; int pulled = max(0, gestureStartY - fingerY); float target = constrain(1.0f - pulled / max(1.0f, screenH * .55f), .28f, 1.0f); floatingCX += (fingerX - floatingCX) * .42f; floatingCY += ((fingerY - appIconSize * target * .5f - 18) - floatingCY) * .42f; floatingScale += (target - floatingScale) * .35f; drawFloatingCard(floatingCX, floatingCY, floatingScale); return; }
  if (selectedApp == VOICE_MEMOS_APP && (screenState == ScreenState::App || screenState == ScreenState::GestureArmed)) {
    bool wasRecording = voiceRecording;
    updateVoiceMemosRecording();
    if (voicePlaying && !M5.Speaker.isPlaying()) { voicePlaying = false; if (voicePlayback) { free(voicePlayback); voicePlayback = nullptr; } renderVoiceMemosCache(); showApp(); }
    else if (wasRecording && now - lastFrameAt >= 100) { lastFrameAt = now; renderVoiceMemosCache(); showApp(); }
  }
  if (gestureTappedAt && now - gestureTappedAt <= GESTURE_HOLD_MS + GESTURE_FADE_MS) refreshGestureBar(now);
  refreshTopBar(now);
  if (pressed && screenState == ScreenState::Home) {
    for (int i = 0; i < APP_COUNT; ++i) {
      bool gridHit = x >= homeIconX[i] && x <= homeIconX[i] + iconSize && y >= homeIconY[i] && y <= homeIconY[i] + iconSize + 34;
      bool dockHit = x >= dockIconX[i] && x <= dockIconX[i] + dockIconSize && y >= dockIconY && y <= dockIconY + dockIconSize;
      if (gridHit || dockHit) {
        selectedApp = i;
        screenState = selectedApp == SETTINGS_APP ? ScreenState::Settings : ScreenState::App;
        if (screenState == ScreenState::Settings) showSettings(); else showApp();
        return;
      }
    }
  }
  if (screenState == ScreenState::App && selectedApp == 1) {
    if (!pressed) return;
    if (y >= TOP_BAR_HEIGHT + 12 && y <= TOP_BAR_HEIGHT + 56 && x >= screenW - 160) { fetchBrowserPage(browserQuery.c_str()); return; }
    if (y >= TOP_BAR_HEIGHT + 108 && y <= TOP_BAR_HEIGHT + 150) {
      if (x < screenW / 3) browserQuery = "technology news";
      else if (x < screenW * 2 / 3) browserQuery = "nature photography";
      else browserQuery = "space exploration";
      renderBrowserCache(); showApp(); return;
    }
  }
  if (screenState == ScreenState::App && selectedApp == VOICE_MEMOS_APP) {
    if (!pressed) return;
    const int voiceButtonY = screenH - 168;
    if (y >= voiceButtonY && y <= voiceButtonY + 62) {
      if (voiceRecording) stopVoiceMemo(); else startVoiceMemo();
      renderVoiceMemosCache(); showApp(); return;
    }
    if (y >= voiceButtonY + 78 && y <= voiceButtonY + 124) { playVoiceMemo(); renderVoiceMemosCache(); showApp(); return; }
  }
  if (screenState == ScreenState::App && selectedApp == GITCODE_APP) {
    if (!pressed) return;
    if (y >= screenH - 150 && y <= screenH - 64 && gitcodeInstalled) { buildGitCodeApp(); renderGitCodeCache(); showApp(); return; }
  }
  if (screenState == ScreenState::App && selectedApp == 2) {
    if (!pressed) return;
    if (filesViewingImage) {
      // Tap the Files heading to return to the list. The bottom gesture area
      // remains available to return Home just like every other app screen.
      if (y < screenH - max(120, screenH / 8)) { filesViewingImage = false; renderFilesCache(); showApp(); return; }
    } else {
      if (y >= TOP_BAR_HEIGHT + 40 && y <= TOP_BAR_HEIGHT + 92 && x >= screenW - 180) { filesUseSd = !filesUseSd; renderFilesCache(); showApp(); return; }
      int drawingRow = TOP_BAR_HEIGHT + 194;
      if (filesHasExport && y >= drawingRow && y <= drawingRow + 90) { filesViewingImage = true; renderFilesImageViewer(); showApp(); return; }
    }
    fs::FS *store = filesUseSd && SD_MMC.cardType() != CARD_NONE ? static_cast<fs::FS *>(&SD_MMC) : static_cast<fs::FS *>(&SPIFFS);
    if (y >= TOP_BAR_HEIGHT + 108 && y <= TOP_BAR_HEIGHT + 172) {
      String path = x < screenW / 2 ? "/new-file-" + String(filesSequence++) + ".txt" : "/new-folder-" + String(filesSequence++);
      if (x < screenW / 2) { File f = store->open(path, FILE_WRITE); if (f) f.close(); } else store->mkdir(path);
      renderFilesCache(); showApp(); return;
    }
  }
  // Drawing stays in the canvas sprite. Toolbar redraws never repaint that
  // canvas, so choosing a colour cannot erase the user's work.
  if (screenState == ScreenState::App && selectedApp == 0) {
    const int toolY = TOP_BAR_HEIGHT + 50, colourY = TOP_BAR_HEIGHT + 110;
    const int paperY = TOP_BAR_HEIGHT + 152, paperBottom = screenH - 76;
    if (freeformHoldMenu) {
      if (!pressed) return;
      const int menuY = (screenH - 292) / 2;
      if (y >= menuY + 43 && y < menuY + 86) {
        appScreens[0].fillRoundRect(18, paperY, screenW - 36, paperBottom - paperY, 18, UI_WHITE);
        freeformHoldMenu = false; freeformDrawing = false; showApp(); return;
      }
      if (y >= menuY + 128 && y < menuY + 171) {
        setFreeformAsBackground(); freeformHoldMenu = false; freeformDrawing = false; showApp(); return;
      }
      if (y >= menuY + 178 && y < menuY + 221) {
        exportFreeformToFiles(); freeformHoldMenu = false; freeformDrawing = false; showApp(); return;
      }
      freeformHoldMenu = false; showApp(); return;
    }
    if (freeformColorPicker) {
      if (!pressed) return;
      const int pickerW = min(screenW - 56, 520), pickerH = min(screenH - 180, 610);
      const int pickerX = (screenW - pickerW) / 2, pickerY = max(72, (screenH - pickerH) / 2);
      const int gridX = pickerX + 22, gridY = pickerY + 112, gridW = pickerW - 44, gridH = min(250, pickerH - 250);
      if (x >= pickerX + pickerW - 70 && y <= pickerY + 60) { freeformColorPicker = false; showApp(); return; }
      if (x >= gridX && x < gridX + gridW && y >= gridY && y < gridY + gridH) {
        int col = constrain((x - gridX) * 12 / gridW, 0, 11), row = constrain((y - gridY) * 8 / gridH, 0, 7);
        float hue = col / 12.0f, value = 1.0f - row * 0.105f, sat = row < 6 ? 0.86f : 0.35f;
        float rr = constrain(fabsf(hue * 6 - 3) - 1, 0, 1), gg = constrain(2 - fabsf(hue * 6 - 2), 0, 1), bb = constrain(2 - fabsf(hue * 6 - 4), 0, 1);
        freeformInk = ((int)(31 * (1 - sat + sat * rr) * value) << 11) | ((int)(63 * (1 - sat + sat * gg) * value) << 5) | (int)(31 * (1 - sat + sat * bb) * value);
        freeformColorPicker = false; drawFreeformToolbar(appScreens[0]); showApp(); return;
      }
      return;
    }
    if (pressed && y >= toolY && y <= toolY + 42) {
      int index = constrain((x - 16) / max(1, ((screenW - 32) / 7)), 0, 6);
      freeformTool = static_cast<FreeformTool>(index); drawFreeformToolbar(appScreens[0]); showApp(); return;
    }
    if (pressed && y >= colourY - 12 && y <= colourY + 44) {
      if (x >= screenW - 170) { exportFreeformToFiles(); M5.Display.fillRoundRect(110, screenH / 2 - 58, screenW - 220, 116, 16, CARD); M5.Display.setTextDatum(textdatum_t::middle_center); setSystemFont(M5.Display, 26); M5.Display.setTextColor(UI_WHITE, CARD); M5.Display.drawString(filesHasExport ? "Exported to Files" : "Export failed", screenW / 2, screenH / 2); return; }
      freeformColorPicker = true; drawFreeformColorPicker(); return;
    }
    if (touching && x >= 18 && x <= screenW - 19 && y >= paperY && y <= paperBottom) {
      if (!freeformDrawing) { freeformLastX = freeformStartX = x; freeformLastY = freeformStartY = y; freeformDrawing = true; freeformStrokeMoved = false; freeformTouchStartedAt = now; if (freeformTool == FreeformTool::Fill) { appScreens[0].fillRoundRect(18, paperY, screenW - 36, paperBottom - paperY, 18, freeformInk); showApp(); freeformDrawing = false; return; } }
      if (abs(x - freeformStartX) + abs(y - freeformStartY) > 14) freeformStrokeMoved = true;
      if (!freeformStrokeMoved && now - freeformTouchStartedAt >= 650) { freeformHoldMenu = true; freeformDrawing = false; drawFreeformHoldMenu(); return; }
      uint16_t ink = freeformTool == FreeformTool::Eraser ? UI_WHITE : freeformInk; int width = freeformTool == FreeformTool::Brush ? 10 : 3;
      if (freeformTool == FreeformTool::Pen || freeformTool == FreeformTool::Brush || freeformTool == FreeformTool::Eraser) { appScreens[0].drawLine(freeformLastX, freeformLastY, x, y, ink); appScreens[0].fillCircle(x, y, width, ink); M5.Display.drawLine(freeformLastX, freeformLastY, x, y, ink); M5.Display.fillCircle(x, y, width, ink); freeformLastX = x; freeformLastY = y; }
      return;
    }
    if (!touching && freeformDrawing) { int l = min(freeformStartX, freeformLastX), t = min(freeformStartY, freeformLastY), w = abs(freeformLastX - freeformStartX), h = abs(freeformLastY - freeformStartY); if (freeformTool == FreeformTool::Circle) appScreens[0].drawEllipse(freeformStartX, freeformStartY, max(2, w), max(2, h), freeformInk); else if (freeformTool == FreeformTool::Square) appScreens[0].drawRect(l, t, max(2, w), max(2, h), freeformInk); else if (freeformTool == FreeformTool::RoundSquare) appScreens[0].drawRoundRect(l, t, max(2, w), max(2, h), 16, freeformInk); if (freeformTool == FreeformTool::Circle || freeformTool == FreeformTool::Square || freeformTool == FreeformTool::RoundSquare) showApp(); freeformDrawing = false; }
  }
  if (!pressed) return;
  if (screenState == ScreenState::App && y >= screenH - max(120, screenH / 8)) { gestureTappedAt = now; gestureStartedAt = now; screenState = ScreenState::GestureArmed; gestureStartY = gestureLastY = y; refreshGestureBar(now); }
}

Remix this project

Make it yours in one click

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

Open in Schematik