#include "first_run_welcome_screen.h" #include "touch_input.h" #include "menu_stars.h" #include "config.h" #include "sd_storage.h" #include namespace FirstRunWelcomeScreen { namespace { struct Rect { int16_t x, y, w, h; bool contains(int16_t px, int16_t py) const { return px >= x && px < x + w && py >= y && py < y + h; } }; // Text intentionally hardcoded (English) instead of via i18n - this // screen runs BEFORE language selection (see first_run_language_screen.cpp // for the same pattern with "Language / Sprache"), so no language has // been chosen yet. constexpr const char* TITLE = "Welcome to Leo's Flightradar!"; // Centered, bold (1px-offset technique, same as the "Flightradar" // button in first_run_complete_screen.cpp) word-wrapped title - // variant of layoutWrapped() (see convention in // first_run_location_screen.cpp: every screen keeps its own small // helpers instead of a shared module), but using MC_DATUM instead of // a left-aligned cursor since the title should be bigger and centered // here (this screen represents the app). int16_t drawCenteredWrappedBold(TFT_eSPI& tft, int16_t cx, int16_t startY, int16_t maxWidth, int16_t lineHeight, uint8_t textSize, const String& text) { tft.setTextDatum(MC_DATUM); tft.setTextSize(textSize); int16_t y = startY; int32_t start = 0; int32_t len = text.length(); while (start < len) { while (start < len && text[start] == ' ') start++; if (start >= len) break; String line = text.substring(start, len); while (tft.textWidth(line) > maxWidth) { int32_t lastSpace = line.lastIndexOf(' '); if (lastSpace <= 0) break; line = line.substring(0, lastSpace); } int16_t lineCy = (int16_t)(y + lineHeight / 2); tft.drawString(line, cx, lineCy); tft.drawString(line, (int16_t)(cx + 1), lineCy); y += lineHeight; start += line.length(); } tft.setTextSize(1); tft.setTextDatum(TL_DATUM); return y; } // ---- Radar graphic, based on the splash screen (splash_screen.cpp) ---- // Deliberately duplicated locally (no shared UI modules between // screens, see convention in first_run_location_screen.cpp), but // extended with a "cy" parameter: the splash screen hardcodes // everything for cy=174, here the same graphic (circle, mini jets, // mini helicopters, big airplane silhouette) needs to sit at a // different vertical position since this screen also needs room for // a title and a button. Radius deliberately a bit smaller than on the // splash screen (72 instead of 80, same ring ratios kept) - at the // full splash radius it looked too cramped between title and button. void drawRadarReticle(TFT_eSPI& tft, int16_t cx, int16_t cy) { uint16_t dim = 0x0320; tft.drawCircle(cx, cy, 72, dim); tft.drawCircle(cx, cy, 49, dim); tft.drawCircle(cx, cy, 26, dim); tft.drawFastHLine((int16_t)(cx - 72), cy, 144, dim); tft.drawFastVLine(cx, (int16_t)(cy - 72), 144, dim); } void rotatePoint(int16_t x, int16_t y, float sinA, float cosA, float px, float py, int16_t& outX, int16_t& outY) { outX = x + (int16_t)lroundf(px * cosA - py * sinA); outY = y + (int16_t)lroundf(px * sinA + py * cosA); } // Identical to splash_screen.cpp::drawMiniJet (already takes an // absolute center point, no change needed). void drawMiniJet(TFT_eSPI& tft, int16_t x, int16_t y, uint16_t color, float angleDeg) { float rad = angleDeg * (PI / 180.0f); float s = sinf(rad), c = cosf(rad); int16_t nx, ny, flx, fly, frx, fry; rotatePoint(x, y, s, c, 0, -11, nx, ny); rotatePoint(x, y, s, c, -3, 11, flx, fly); rotatePoint(x, y, s, c, 3, 11, frx, fry); tft.fillTriangle(nx, ny, flx, fly, frx, fry, color); int16_t wlx, wly, wrx, wry, wcx, wcy; rotatePoint(x, y, s, c, -13, 3, wlx, wly); rotatePoint(x, y, s, c, 13, 3, wrx, wry); rotatePoint(x, y, s, c, 0, -3, wcx, wcy); tft.fillTriangle(wlx, wly, wrx, wry, wcx, wcy, color); int16_t tlx, tly, trx, try_, ttx, tty; rotatePoint(x, y, s, c, -5, 8, tlx, tly); rotatePoint(x, y, s, c, 5, 8, trx, try_); rotatePoint(x, y, s, c, 0, 13, ttx, tty); tft.fillTriangle(tlx, tly, trx, try_, ttx, tty, color); } // Identical to splash_screen.cpp::drawMiniHeli (already takes an // absolute center point, no change needed). void drawMiniHeli(TFT_eSPI& tft, int16_t x, int16_t y, uint16_t color, float angleDeg) { float rad = angleDeg * (PI / 180.0f); float s = sinf(rad), c = cosf(rad); int16_t rcx, rcy; rotatePoint(x, y, s, c, 0, -7, rcx, rcy); tft.drawCircle(rcx, rcy, 6, color); int16_t b1x, b1y, b2x, b2y; rotatePoint(x, y, s, c, -7, -7, b1x, b1y); rotatePoint(x, y, s, c, 7, -7, b2x, b2y); tft.drawLine(b1x, b1y, b2x, b2y, color); rotatePoint(x, y, s, c, 0, -13, b1x, b1y); rotatePoint(x, y, s, c, 0, -1, b2x, b2y); tft.drawLine(b1x, b1y, b2x, b2y, color); int16_t cx1, cy1, cx2, cy2, cx3, cy3; rotatePoint(x, y, s, c, 0, -6, cx1, cy1); rotatePoint(x, y, s, c, -4, 4, cx2, cy2); rotatePoint(x, y, s, c, 4, 4, cx3, cy3); tft.fillTriangle(cx1, cy1, cx2, cy2, cx3, cy3, color); int16_t tbx1, tby1, tbx2, tby2; rotatePoint(x, y, s, c, 0, 4, tbx1, tby1); rotatePoint(x, y, s, c, 0, 12, tbx2, tby2); tft.drawLine(tbx1, tby1, tbx2, tby2, color); int16_t fx1, fy1, fx2, fy2, fx3, fy3; rotatePoint(x, y, s, c, -3, 10, fx1, fy1); rotatePoint(x, y, s, c, 3, 10, fx2, fy2); rotatePoint(x, y, s, c, 0, 15, fx3, fy3); tft.fillTriangle(fx1, fy1, fx2, fy2, fx3, fy3, color); } // splash_screen.cpp::drawAirplane hardcodes all coordinates absolutely // (fixed for its cy=174) - here recomputed relative to a passed-in cy // instead, so the exact same shape results for any cy (see comment // above drawRadarReticle). void drawAirplane(TFT_eSPI& tft, int16_t cx, int16_t cy) { uint16_t color = TFT_GREEN; tft.drawTriangle((int16_t)(cx + 15), (int16_t)(cy - 32), (int16_t)(cx - 15), (int16_t)(cy + 26), (int16_t)(cx - 10), (int16_t)(cy + 28), color); tft.drawTriangle((int16_t)(cx + 5), (int16_t)(cy - 10), (int16_t)(cx - 47), (int16_t)(cy + 1), (int16_t)(cx - 3), (int16_t)(cy + 5), color); tft.drawTriangle((int16_t)(cx + 5), (int16_t)(cy - 10), (int16_t)(cx + 30), (int16_t)(cy + 37), (int16_t)(cx - 3), (int16_t)(cy + 5), color); tft.drawTriangle((int16_t)(cx - 11), (int16_t)(cy + 24), (int16_t)(cx - 23), (int16_t)(cy + 27), (int16_t)(cx - 6), (int16_t)(cy + 36), color); } // Bundles reticle + all planes/helicopters into exactly the graphic // shown on the splash screen ("radar bigger with all the aircraft, // exactly like on the splash screen"). void drawRadarGroup(TFT_eSPI& tft, int16_t cx, int16_t cy) { drawRadarReticle(tft, cx, cy); uint16_t dimGreen = 0x0320; drawMiniJet(tft, (int16_t)(cx - 45), (int16_t)(cy - 39), dimGreen, 25.0f); drawMiniJet(tft, (int16_t)(cx + 50), (int16_t)(cy - 14), dimGreen, 160.0f); drawMiniHeli(tft, (int16_t)(cx - 35), (int16_t)(cy + 41), dimGreen, 250.0f); drawMiniHeli(tft, (int16_t)(cx + 45), (int16_t)(cy - 59), dimGreen, 100.0f); drawAirplane(tft, cx, cy); } // Button frame in the usual style (black fill, thin green border, // rounded corners) - deliberately NOT fully green-filled like the // "Flightradar" button in first_run_complete_screen.cpp, so the // background stars (MenuStars, drawn across the whole screen anyway) // stay visible through the button ("button should be black with // stars"). void drawButtonFrame(TFT_eSPI& tft, const Rect& r) { tft.fillRoundRect(r.x, r.y, r.w, r.h, 8, TFT_BLACK); tft.drawRoundRect(r.x, r.y, r.w, r.h, 8, TFT_GREEN); } // MC_DATUM centers based on the font metrics, but the built-in // TFT_eSPI font renders a few pixels too low with it (a known quirk of // that font) - hence a small manual correction offset upward. constexpr int16_t START_TEXT_Y_OFFSET = -3; // "Start" text bold (drawn twice with a 1px offset) and "breathing" - // see updateBreathe() below for the color logic. void drawStartText(TFT_eSPI& tft, const Rect& r, uint16_t color) { tft.setTextDatum(MC_DATUM); tft.setTextColor(color, TFT_BLACK); tft.setTextSize(2); int16_t cx = r.x + r.w / 2; int16_t cy = (int16_t)(r.y + r.h / 2 + START_TEXT_Y_OFFSET); tft.drawString("Start", cx, cy); tft.drawString("Start", (int16_t)(cx + 1), cy); tft.setTextSize(1); tft.setTextDatum(TL_DATUM); } uint8_t breathePhase = 0; uint32_t lastBreatheMs = 0; constexpr uint32_t BREATHE_INTERVAL_MS = 20; constexpr uint8_t BREATHE_SPEED = 2; // ~256/2 ticks at 20ms => ~2.5s per breath constexpr uint8_t BREATHE_LOW_R = 0, BREATHE_LOW_G = 170, BREATHE_LOW_B = 0; constexpr uint8_t BREATHE_HIGH_R = 110, BREATHE_HIGH_G = 255, BREATHE_HIGH_B = 110; void updateBreathe(TFT_eSPI& tft, const Rect& btn) { uint32_t now = millis(); if (now - lastBreatheMs < BREATHE_INTERVAL_MS) return; lastBreatheMs = now; breathePhase = (uint8_t)(breathePhase + BREATHE_SPEED); uint8_t tri = (breathePhase < 128) ? (uint8_t)(breathePhase * 2) : (uint8_t)((255 - breathePhase) * 2); uint8_t r = (uint8_t)(BREATHE_LOW_R + ((BREATHE_HIGH_R - BREATHE_LOW_R) * (uint16_t)tri) / 255); uint8_t g = (uint8_t)(BREATHE_LOW_G + ((BREATHE_HIGH_G - BREATHE_LOW_G) * (uint16_t)tri) / 255); uint8_t b = (uint8_t)(BREATHE_LOW_B + ((BREATHE_HIGH_B - BREATHE_LOW_B) * (uint16_t)tri) / 255); drawStartText(tft, btn, tft.color565(r, g, b)); } // Loading indicator: three dots that light up green one by one - // replaces the "Start" text in the button while the SD card is // accessed in the background (creating the folder structure + seeding // default data files, see runStartSequence()). Without this immediate // feedback the button looked "frozen" after being tapped and people // tapped again - which caused a tap to get stuck mid-SD-access and be // picked up as the first calibration tap at the wrong position (see // runStartSequence()). void drawLoadingDots(TFT_eSPI& tft, const Rect& r, uint8_t litCount) { tft.fillRect((int16_t)(r.x + 2), (int16_t)(r.y + 2), (int16_t)(r.w - 4), (int16_t)(r.h - 4), TFT_BLACK); int16_t cy = (int16_t)(r.y + r.h / 2); constexpr int16_t SPACING = 16; int16_t startX = (int16_t)(r.x + r.w / 2 - SPACING); for (uint8_t i = 0; i < 3; i++) { int16_t dx = (int16_t)(startX + i * SPACING); if (i < litCount) { tft.fillCircle(dx, cy, 4, TFT_GREEN); } else { tft.drawCircle(dx, cy, 4, TFT_GREEN); } } } // Runs when "Start" is tapped: immediately shows the loading indicator // (instead of the "Start" text), then does the noticeably slow SD // accesses (create folder structure + seed default data files - see // sd_storage.cpp), and finally drains any lingering touch state before // the next screen (usually touch calibration) starts polling for taps. // // The touch flush at the end is not cosmetic: without it, impatient // repeated tapping on the (unresponsive during the SD access) button // could leave a touch still "down" right as the next screen starts // polling - e.g. getting picked up on the calibration screen as a // (wrongly positioned) first target tap, which then skewed the whole // calibration and, as a knock-on effect, caused taps elsewhere (like // in the WiFi network list) to land in the wrong place too. void runStartSequence(TFT_eSPI& tft, const Rect& r) { drawLoadingDots(tft, r, 0); SdStorage::createStructure(); drawLoadingDots(tft, r, 1); SdStorage::seedDefaultDataFiles(); drawLoadingDots(tft, r, 2); delay(150); drawLoadingDots(tft, r, 3); delay(150); // Wait out and discard any lingering touch - see comment above. while (TouchInput::rawPoint().touched) { delay(10); } delay(150); } } void run(TFT_eSPI& tft) { MenuStars::reset(); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN, TFT_BLACK); // Title: bigger (size 2), bold, centered - "the headline and logo are // more important", so much more prominent than the previous small // left-aligned version. int16_t textEndY = drawCenteredWrappedBold(tft, (int16_t)(Config::SCREEN_WIDTH / 2), 14, (int16_t)(Config::SCREEN_WIDTH - 20), 24, 2, TITLE); // Button size computed from the actual width/height of the "Start" // text instead of hardcoded ("make the button size variable") - // keeps the button compact and centered instead of spanning the // full screen width like before. tft.setTextSize(2); int16_t startTextW = tft.textWidth("Start"); tft.setTextSize(1); constexpr int16_t BTN_PAD_X = 26; constexpr int16_t BTN_PAD_Y = 14; constexpr int16_t BTN_TEXT_H = 16; // default-font glyph height at setTextSize(2) constexpr int16_t BTN_MARGIN_BOTTOM = 16; int16_t btnW = (int16_t)(startTextW + 1 + BTN_PAD_X * 2); // +1 for the bold offset int16_t btnH = (int16_t)(BTN_TEXT_H + BTN_PAD_Y * 2); int16_t btnY = (int16_t)(Config::SCREEN_HEIGHT - BTN_MARGIN_BOTTOM - btnH); int16_t btnX = (int16_t)((Config::SCREEN_WIDTH - btnW) / 2); Rect startBtn = {btnX, btnY, btnW, btnH}; // Radar graphic: gets more room than before thanks to the more compact // centered title and the smaller button, centered in the remaining // space - exactly the same graphic as the splash screen // (drawRadarGroup), just with a dynamically computed cy instead of // splash_screen.cpp's hardcoded cy=174. int16_t availTop = (int16_t)(textEndY + 4); int16_t availBottom = (int16_t)(btnY - 8); int16_t cy = (int16_t)(availTop + (availBottom - availTop) / 2); drawRadarGroup(tft, (int16_t)(Config::SCREEN_WIDTH / 2), cy); drawButtonFrame(tft, startBtn); drawStartText(tft, startBtn, TFT_GREEN); while (true) { TouchInput::Point tap; if (TouchInput::wasTapped(tap)) { if (startBtn.contains(tap.x, tap.y)) { runStartSequence(tft, startBtn); return; } continue; } MenuStars::update(tft); updateBreathe(tft, startBtn); delay(20); } } }