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

@@ -15,11 +15,19 @@ namespace {
int16_t avgY() const { return n ? (int16_t)(sumY / n) : 0; }
};
// Mini radar target: same look as the radar graphic on the splash/
// welcome screen (concentric rings + crosshair in green), but without
// aircraft - instead a green filled center point as the point to aim
// at, replacing the previous red filled circle with white center dot.
// Radii deliberately kept small so the targets in the screen corners
// (edge margin M=24, see run()) don't extend past the screen edge.
void drawTarget(TFT_eSPI& tft, int16_t x, int16_t y) {
tft.fillCircle(x, y, 10, TFT_RED);
tft.drawFastHLine(x - 16, y, 32, TFT_RED);
tft.drawFastVLine(x, y - 16, 32, TFT_RED);
tft.fillCircle(x, y, 3, TFT_WHITE);
uint16_t dim = 0x0320;
tft.drawCircle(x, y, 16, dim);
tft.drawCircle(x, y, 10, dim);
tft.drawFastHLine((int16_t)(x - 18), y, 36, TFT_GREEN);
tft.drawFastVLine(x, (int16_t)(y - 18), 36, TFT_GREEN);
tft.fillCircle(x, y, 3, TFT_GREEN);
}
RawAvg waitForTap(TFT_eSPI& tft) {

View File

@@ -6,7 +6,7 @@ namespace Config {
// CLAUDE.md-Workflow "Standard-Workflow: Push & Release") - erscheint
// im Info-Screen (Menue > System > Info) und muss zum jeweiligen
// Git-Tag passen.
constexpr const char* APP_VERSION = "3.2.0";
constexpr const char* APP_VERSION = "3.3.0";
// Display-Helligkeit (Menue > System > Helligkeit), in Prozent.
// MIN bewusst nicht 0 - ein komplett dunkles Display koennte sonst wie

View File

@@ -36,16 +36,28 @@ namespace {
return y;
}
void drawStartButton(TFT_eSPI& tft, const Rect& r) {
tft.fillRoundRect(r.x, r.y, r.w, r.h, 6, TFT_GREEN);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 6, TFT_GREEN);
// Button frame in the same style as the Start button on the Welcome
// screen (first_run_welcome_screen.cpp::drawButtonFrame) - black fill,
// thin green border, rounded corners. Deliberately no longer fully
// green-filled like the old version of this button, so both first-run
// screens look consistent.
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);
}
// Big, bold (1px-offset technique, same as the "Start" text on the
// Welcome screen) countdown digit in the middle of the button -
// replaces the previous "Flightradar" text.
void drawCountdownText(TFT_eSPI& tft, const Rect& r, const String& text) {
tft.fillRect((int16_t)(r.x + 2), (int16_t)(r.y + 2), (int16_t)(r.w - 4), (int16_t)(r.h - 4), TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_BLACK, TFT_GREEN);
tft.setTextSize(2);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextSize(3);
int16_t cx = r.x + r.w / 2;
int16_t cy = r.y + r.h / 2;
tft.drawString("Flightradar", cx, cy);
tft.drawString("Flightradar", (int16_t)(cx + 1), cy);
tft.drawString(text, cx, cy);
tft.drawString(text, (int16_t)(cx + 1), cy);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
}
@@ -70,12 +82,32 @@ void run(TFT_eSPI& tft) {
if (btnY > maxBtnY) btnY = maxBtnY;
Rect startBtn = {10, btnY, (int16_t)(Config::SCREEN_WIDTH - 20), BTN_H};
drawStartButton(tft, startBtn);
drawButtonFrame(tft, startBtn);
while (true) {
TouchInput::Point tap;
if (!TouchInput::wasTapped(tap)) { MenuStars::update(tft); delay(20); continue; }
if (startBtn.contains(tap.x, tap.y)) return;
// Automatic countdown in the button instead of a tappable
// "Flightradar" text - continues automatically into the splash
// screen/app afterward. Tapping the button during the countdown
// skips the wait immediately (same expectation as other buttons in
// the app). 7 seconds total (7-6-5-4-3-2-1, 1 second each) instead of
// the original 3 seconds - a bit more time to read the message above
// before it automatically continues.
constexpr uint32_t COUNTDOWN_STEP_MS = 1000;
constexpr int8_t COUNTDOWN_START = 7;
for (int8_t count = COUNTDOWN_START; count >= 1; count--) {
drawCountdownText(tft, startBtn, String(count));
uint32_t stepStart = millis();
bool skipped = false;
while (millis() - stepStart < COUNTDOWN_STEP_MS) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap) && startBtn.contains(tap.x, tap.y)) {
skipped = true;
break;
}
MenuStars::update(tft);
delay(20);
}
if (skipped) break;
}
}

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);

View File

@@ -456,10 +456,21 @@ void setup() {
haltWithSdRequiredScreen();
return;
}
SdStorage::seedDefaultDataFiles();
bool isFirstRun = !SD.exists(Config::SD_SETTINGS_FILE);
// The Flightradar folder structure and default data files are
// deliberately NOT created yet on a first boot - that happens further
// down, after the Start button on the Welcome screen has been tapped
// (the button is "the gate to the app": nothing should exist on the
// card before that). On every later boot the structure already
// exists anyway - creating it immediately here is harmless,
// ensureDir()/writeIfAbsent() are idempotent.
if (!isFirstRun) {
SdStorage::createStructure();
SdStorage::seedDefaultDataFiles();
}
SettingsStore::load();
tft.invertDisplay(SettingsStore::displayInverted());
// Der erste ledcWrite() oben (vor SettingsStore::load()) kannte die
@@ -488,6 +499,12 @@ void setup() {
// da die Sprachauswahl (FirstRunLanguageScreen) erst danach kommt,
// siehe first_run_welcome_screen.cpp.
if (isFirstRun) {
// Creating the folder structure, seeding default data files, AND
// showing the loading indicator during those (noticeably slow) SD
// accesses now all happen directly inside
// FirstRunWelcomeScreen::run() (triggered by the Start tap) - see
// there for why (button otherwise looked frozen, repeated tapping
// let a tap leak into calibration).
FirstRunWelcomeScreen::run(tft);
}

View File

@@ -108,16 +108,19 @@ bool init() {
sdSpi.begin(Config::SD_SPI_CLK_PIN, Config::SD_SPI_MISO_PIN,
Config::SD_SPI_MOSI_PIN, Config::SD_SPI_CS_PIN);
mounted = SD.begin(Config::SD_SPI_CS_PIN, sdSpi, 4000000);
if (!mounted) return false;
ensureDir(Config::SD_ROOT_DIR);
ensureDir(Config::SD_LOG_DIR);
ensureDir(Config::SD_SCREENSHOT_DIR);
return true;
return mounted;
}
bool isMounted() { return mounted; }
void createStructure() {
if (!mounted) return;
SdMutex::Guard guard;
ensureDir(Config::SD_ROOT_DIR);
ensureDir(Config::SD_LOG_DIR);
ensureDir(Config::SD_SCREENSHOT_DIR);
}
void seedDefaultDataFiles() {
if (!mounted) return;
SdMutex::Guard guard;

View File

@@ -5,6 +5,14 @@ namespace SdStorage {
bool init();
bool isMounted();
// Creates the Flightradar folder structure (root/log/screenshot dirs)
// on the SD card - deliberately split out from init() (which now only
// mounts the card), so that on the very first boot nothing gets
// created on the card until the user has tapped Start on the Welcome
// screen (see main.cpp: the button is "the gate to the app"). Safe to
// call immediately on every later boot - ensureDir() is idempotent.
void createStructure();
void seedDefaultDataFiles();
void logEvent(const char* csvLine);

View File

@@ -185,6 +185,26 @@ namespace {
void drawCancelButton(TFT_eSPI& tft) {
drawButton(tft, cancelBtn, "X");
}
// Small loading indicator ("."/".."/"...") during the WiFi scan (which
// can take several seconds) - without visible feedback the screen
// looked "frozen" during that time and people tapped impatiently,
// which (see comment at the end of the Scanning->PickSsid transition
// below) could cause mistaps in the freshly shown network list.
uint8_t scanDotPhase = 0;
uint32_t lastScanDotMs = 0;
constexpr uint32_t SCAN_DOT_INTERVAL_MS = 350;
void updateScanningDots(TFT_eSPI& tft) {
uint32_t now = millis();
if (now - lastScanDotMs < SCAN_DOT_INTERVAL_MS) return;
lastScanDotMs = now;
scanDotPhase = (uint8_t)((scanDotPhase + 1) % 4);
tft.fillRect(10, 26, 40, 12, TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 26);
for (uint8_t i = 0; i < scanDotPhase; i++) tft.print(".");
}
}
bool run(TFT_eSPI& tft) {
@@ -197,6 +217,8 @@ bool run(TFT_eSPI& tft) {
scrollOffset = 0;
resetKeyboardState();
needsRedraw = true;
scanDotPhase = 0;
lastScanDotMs = 0;
WifiMgr::beginScan();
@@ -227,6 +249,16 @@ bool run(TFT_eSPI& tft) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.println(ssidCount == 0 ? I18n::t(StringId::WIFI_NO_NETWORKS) : I18n::t(StringId::WIFI_SELECT));
drawCancelButton(tft);
// "tapped"/"tap" were captured for the previous state
// (Stage::Scanning) - without discarding it, a tap that
// happened to land exactly in the frame the scan finished
// would immediately be evaluated below against the
// BRAND-NEW (and, to the user, not yet visible) network list
// - at a position the user never intentionally tapped. That
// is what let impatient repeated tapping during the scan
// eventually pick a "random" wrong network.
tapped = false;
}
if (stage == Stage::Connecting) {
@@ -268,6 +300,15 @@ bool run(TFT_eSPI& tft) {
resetKeyboardState();
stage = Stage::EnterPassword;
needsRedraw = true;
// Prevents this same tap (on a network name) from
// also being evaluated against the password
// keyboard further down in the same pass - "stage"
// is already EnterPassword at this point, and the
// network list rows and keyboard key rows
// partially overlap in Y, so without this a
// network selection could immediately also type a
// stray character.
tapped = false;
}
}
if (ssidCount > VISIBLE_ITEMS) {
@@ -334,6 +375,7 @@ bool run(TFT_eSPI& tft) {
}
if (!needsRedraw) {
if (stage == Stage::Scanning) updateScanningDots(tft);
MenuStars::update(tft);
delay(20);
continue;