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

@@ -28,7 +28,7 @@ You can flash the firmware directly from your browser to your CYD display withou
2. Insert a FAT32-formatted microSD card into your display.
3. On first boot: Calibrate the touchscreen and configure your Wi-Fi via the built-in on-screen keyboard.
**Guided first-time setup:** after language selection, a one-time (skippable) screen explains why setting your exact location gives a much better experience than automatic IP-based location, and offers to set it up right there via the address search. A final setup-complete screen then confirms everything is saved and ready before you enter the app.
**Guided first-time setup:** a Welcome screen with the animated radar graphic greets you first, followed by language selection, then a one-time (skippable) screen that explains why setting your exact location gives a much better experience than automatic IP-based location and offers to set it up right there via the address search. A final setup-complete screen then confirms everything is saved and ready, counting down automatically before entering the app (or tap to skip ahead).
**Tip:** for the best experience, set your exact location as a Location Preset (Menu → Flight Options → Location Presets → "+" → Search by address). Just type your address and the device looks up the exact coordinates for you automatically - no need to look anything up yourself. This is much more accurate than automatic IP-based location, which can easily be off by 20-30 km - you might hear a plane outside your window while the radar screen stays empty because the device thinks you're somewhere else entirely. With your precise location set, the proximity LED blinks in sync with aircraft you can actually hear overhead. You'll also be prompted to set this up right during first-time setup.
@@ -58,6 +58,7 @@ You can flash the firmware directly from your browser to your CYD display withou
- **Adjustable brightness** (10-100% in 10% steps, Menu → System → "Brightness") with a live preview as you tap
- **Metric/Imperial units** (Menu → Region → "Units") consistently applied across the radar range button, range ring labels, aircraft list, nearest-airport distance, and stats
- **About screen** (Menu → System → "About") shows the project name, a short description, and the current firmware version
- **Backup & Reset** (Menu → System → "Backup & Reset") – back up or restore your settings, or fully reset the device (wipes all on-device data and re-runs first-time setup) - handy for testing or handing the device to someone else
## Feature Deep-Dive

View File

@@ -5,6 +5,14 @@ framework = arduino
monitor_speed = 115200
upload_speed = 921600
; No OTA (over-the-air firmware update) in use - always flashed via
; cable/web-flasher. The default partition scheme reserves two app
; partitions (~1.25 MB each) plus an unused SPIFFS data area (~1.375 MB) -
; over 2.5 MB of 4 MB flash wasted. "no_ota.csv" (shipped with the
; Espressif framework) uses a single, 2 MB app partition instead - doubles
; the space available for the actual firmware, no code changes needed.
board_build.partitions = no_ota.csv
lib_deps =
bodmer/TFT_eSPI @ ^2.5.43
https://github.com/PaulStoffregen/XPT2046_Touchscreen.git

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;