Add welcome screen at the start of first-time setup, before touch calibration

Shows a new FirstRunWelcomeScreen right at the very beginning of the
first-time setup flow (isFirstRun), before touch calibration even
runs: hard-coded English title (no language chosen yet at this point),
the splash screen's radar reticle centered below it, and a large
black/green-outlined "Start" button whose text breathes between a
darker and a lighter, slightly whitish green (~2.5s cycle) so it's the
one element that visibly stands out against the otherwise uniform
green UI. The button intentionally stays unfilled (black) so the
twinkling MenuStars background shows through it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-12 05:38:11 +02:00
parent 9d2930bd8b
commit ba1c062ef1
3 changed files with 173 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
#include "first_run_welcome_screen.h"
#include "touch_input.h"
#include "menu_stars.h"
#include "config.h"
namespace FirstRunWelcomeScreen {
namespace {
struct Rect {
int16_t x, y, w, h;
bool contains(int16_t px, int16_t py) const {
return px >= x && px < x + w && py >= y && py < y + h;
}
};
// Text 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.
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) {
int16_t y = startY;
int32_t start = 0;
int32_t len = text.length();
while (start < len) {
while (start < len && text[start] == ' ') start++;
if (start >= len) break;
String line = text.substring(start, len);
while (tft.textWidth(line) > maxWidth) {
int32_t lastSpace = line.lastIndexOf(' ');
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
tft.setCursor(x, y);
tft.print(line);
y += lineHeight;
start += line.length();
}
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) {
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);
}
// 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 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);
}
// "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".
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;
tft.drawString("Start", cx, cy);
tft.drawString("Start", (int16_t)(cx + 1), cy);
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
}
uint8_t breathePhase = 0;
uint32_t lastBreatheMs = 0;
constexpr uint32_t BREATHE_INTERVAL_MS = 20;
constexpr uint8_t BREATHE_SPEED = 2; // ~256/2 Ticks a 20ms => ~2.5s pro Atemzug
constexpr uint8_t BREATHE_LOW_R = 0, BREATHE_LOW_G = 170, BREATHE_LOW_B = 0;
constexpr uint8_t BREATHE_HIGH_R = 110, BREATHE_HIGH_G = 255, BREATHE_HIGH_B = 110;
void updateBreathe(TFT_eSPI& tft, const Rect& btn) {
uint32_t now = millis();
if (now - lastBreatheMs < BREATHE_INTERVAL_MS) return;
lastBreatheMs = now;
breathePhase = (uint8_t)(breathePhase + BREATHE_SPEED);
uint8_t tri = (breathePhase < 128) ? (uint8_t)(breathePhase * 2)
: (uint8_t)((255 - breathePhase) * 2);
uint8_t r = (uint8_t)(BREATHE_LOW_R + ((BREATHE_HIGH_R - BREATHE_LOW_R) * (uint16_t)tri) / 255);
uint8_t g = (uint8_t)(BREATHE_LOW_G + ((BREATHE_HIGH_G - BREATHE_LOW_G) * (uint16_t)tri) / 255);
uint8_t b = (uint8_t)(BREATHE_LOW_B + ((BREATHE_HIGH_B - BREATHE_LOW_B) * (uint16_t)tri) / 255);
drawStartText(tft, btn, tft.color565(r, g, b));
}
}
void run(TFT_eSPI& tft) {
MenuStars::reset();
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);
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};
// 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;
drawRadarReticle(tft, Config::SCREEN_WIDTH / 2, midY, radius);
drawButtonFrame(tft, startBtn);
drawStartText(tft, startBtn, TFT_GREEN);
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (startBtn.contains(tap.x, tap.y)) return;
continue;
}
MenuStars::update(tft);
updateBreathe(tft, startBtn);
delay(20);
}
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include <TFT_eSPI.h>
namespace FirstRunWelcomeScreen {
void run(TFT_eSPI& tft);
}

View File

@@ -29,6 +29,7 @@
#include "flight_logbook.h"
#include "weather.h"
#include "i18n.h"
#include "first_run_welcome_screen.h"
#include "first_run_language_screen.h"
#include "first_run_location_screen.h"
#include "first_run_complete_screen.h"
@@ -481,6 +482,15 @@ void setup() {
MenuStars::update(tft);
SplashScreen::setStatusLine(tft, 0, I18n::t(StringId::SPLASH_SD_OK), TFT_WHITE);
// Willkommens-Screen laeuft nur beim allerersten Start (isFirstRun) und
// bewusst VOR der Touch-Kalibrierung - der erste Eindruck, bevor man
// ueberhaupt zum Kalibrieren aufgefordert wird. Englisch hart kodiert,
// da die Sprachauswahl (FirstRunLanguageScreen) erst danach kommt,
// siehe first_run_welcome_screen.cpp.
if (isFirstRun) {
FirstRunWelcomeScreen::run(tft);
}
if (!TouchInput::loadCalibration()) {
CalibrationScreen::run(tft);
}