v3.3.0: Welcome screen, factory reset, calibration/touch fixes, flash partition fix

## New
- "Welcome" screen shown on first-time setup (before touch
  calibration), with the splash screen's animated radar graphic and a
  Start button.
- The "Setup complete" screen now counts down 7 seconds with a button
  matching the Welcome screen's style, then automatically continues
  into the app (previously required a tap).
- System menu "Backup & Reset" page (Backup, Restore, and a new
  "Reset settings" option that wipes the on-device data and re-runs
  first-time setup - useful for testing or handing the device to
  someone else).
- Touch calibration targets are now green mini radar reticles instead
  of red crosshairs.

## Improved
- The SD card's data folder is now only created after tapping Start
  on the Welcome screen, instead of on every boot regardless.
- Flash partition layout changed to free up roughly 850 KB of
  firmware space (no OTA update slot needed, since firmware is always
  flashed via cable/USB) - lowers flash usage from ~95% to ~59%.

## Fixed
- The Start button and the WiFi scanning screen now show a loading
  indicator instead of appearing frozen during SD-card/WiFi
  operations.
- A touch-input bug during first-time setup that could corrupt touch
  calibration and cause taps to land on the wrong on-screen element
  afterward (most noticeable in the WiFi network selection list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-12 06:57:48 +02:00
parent ba1c062ef1
commit a7f14b3125
10 changed files with 394 additions and 84 deletions

View File

@@ -2,6 +2,8 @@
#include "touch_input.h"
#include "menu_stars.h"
#include "config.h"
#include "sd_storage.h"
#include <math.h>
namespace FirstRunWelcomeScreen {
@@ -13,17 +15,24 @@ namespace {
}
};
// Text bewusst hart codiert (Englisch) statt ueber i18n - dieser Screen
// laeuft VOR der Sprachauswahl (siehe first_run_language_screen.cpp fuer
// dasselbe Muster mit "Language / Sprache"), es ist also noch keine
// Sprache gewaehlt.
// 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 Eiswolfs Flightradar!";
// Lokale Kopie, siehe Konvention in first_run_location_screen.cpp (jeder
// Screen haelt seine eigenen kleinen Helfer statt eines gemeinsamen
// Moduls).
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text) {
// 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();
@@ -36,54 +45,163 @@ namespace {
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
tft.setCursor(x, y);
tft.print(line);
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-Reticle wie im Splash-Screen (splash_screen.cpp::drawRadarReticle,
// dort mit fest 80/54/29px) - lokale Kopie statt gemeinsames Modul
// (splash_screen.cpp exportiert die Funktion ohnehin nicht, siehe
// splash_screen.h). Radius wird hier von aussen uebergeben statt fest
// kodiert, da auf diesem Screen (Titel + grosser Start-Button)
// weniger vertikaler Platz uebrig bleibt als im reinen Splash-Screen.
void drawRadarReticle(TFT_eSPI& tft, int16_t cx, int16_t cy, int16_t radius) {
// ---- 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, radius, dim);
tft.drawCircle(cx, cy, (int16_t)(radius * 2 / 3), dim);
tft.drawCircle(cx, cy, (int16_t)(radius / 3), dim);
tft.drawFastHLine((int16_t)(cx - radius), cy, (int16_t)(radius * 2), dim);
tft.drawFastVLine(cx, (int16_t)(cy - radius), (int16_t)(radius * 2), dim);
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);
}
// Button-Rahmen im gewohnten Stil (schwarz gefuellt, duenner gruener
// Rand) - bewusst NICHT komplett gruen ausgefuellt wie der
// "Flightradar"-Button in first_run_complete_screen.cpp, damit die
// Sternchen im Hintergrund (MenuStars, ueberzeichnet ohnehin den ganzen
// Bildschirm inkl. dieser Flaeche) durch den Button hindurch sichtbar
// bleiben ("Button soll schwarz mit Sternen sein").
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, 6, TFT_BLACK);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 6, TFT_GREEN);
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);
}
// "Start"-Text fett (2x mit 1px Versatz gezeichnet - gleiche Technik wie
// beim "Flightradar"-Logo in first_run_complete_screen.cpp, der GFXFF-
// Font hat keine echte Bold-Variante) und "atmend": die Farbe pulsiert
// langsam zwischen einem dunkleren Gruen (passt zu den anderen gruenen
// Elementen: Titel, Rahmen, Radar-Reticle) und einem hellen, leicht
// ins Weissliche gehenden Gruen am Hoehepunkt - NUR "Start" erreicht
// diesen helleren Ton, alles andere bleibt reines TFT_GREEN, dadurch
// ist "Start" immer das Element, das sichtbar "herausleuchtet".
// 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 = r.y + r.h / 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);
@@ -93,7 +211,7 @@ namespace {
uint8_t breathePhase = 0;
uint32_t lastBreatheMs = 0;
constexpr uint32_t BREATHE_INTERVAL_MS = 20;
constexpr uint8_t BREATHE_SPEED = 2; // ~256/2 Ticks a 20ms => ~2.5s pro Atemzug
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;
@@ -110,6 +228,62 @@ namespace {
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) {
@@ -117,26 +291,40 @@ void run(TFT_eSPI& tft) {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
int16_t textEndY = layoutWrapped(tft, 10, 14, (int16_t)(Config::SCREEN_WIDTH - 20), 18, TITLE);
// 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);
constexpr int16_t BTN_H = 90;
constexpr int16_t BTN_MARGIN_BOTTOM = 14;
int16_t btnY = (int16_t)(Config::SCREEN_HEIGHT - BTN_MARGIN_BOTTOM - BTN_H);
Rect startBtn = {10, btnY, (int16_t)(Config::SCREEN_WIDTH - 20), BTN_H};
// 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-Grafik mittig zwischen Titel-Ende und Button-Anfang, Radius aus
// dem tatsaechlich verfuegbaren Platz errechnet statt hart kodiert -
// gleiche Lehre wie bei den anderen Ersteinrichtungs-Screens (Text-
// /Layouthoehe zur Laufzeit messen statt anzunehmen).
int16_t availTop = (int16_t)(textEndY + 6);
int16_t availBottom = (int16_t)(btnY - 10);
int16_t midY = (int16_t)(availTop + (availBottom - availTop) / 2);
int16_t maxRadiusByHeight = (int16_t)((availBottom - availTop) / 2 - 4);
int16_t maxRadiusByWidth = (int16_t)(Config::SCREEN_WIDTH / 2 - 20);
int16_t radius = min(maxRadiusByHeight, maxRadiusByWidth);
if (radius < 20) radius = 20;
// 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);
drawRadarReticle(tft, Config::SCREEN_WIDTH / 2, midY, radius);
drawRadarGroup(tft, (int16_t)(Config::SCREEN_WIDTH / 2), cy);
drawButtonFrame(tft, startBtn);
drawStartText(tft, startBtn, TFT_GREEN);
@@ -144,7 +332,10 @@ void run(TFT_eSPI& tft) {
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (startBtn.contains(tap.x, tap.y)) return;
if (startBtn.contains(tap.x, tap.y)) {
runStartSequence(tft, startBtn);
return;
}
continue;
}
MenuStars::update(tft);