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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
19
src/main.cpp
19
src/main.cpp
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user