Files
flyradar/src/menu_screen.cpp
Eiswolf-BG a5b0e5404a Fix title overflow in confirm/info screens; log checkForUpdate() diagnostics
confirmWarningScreen() and infoScreen() only fell back from text size
2 to size 1 for long titles, but some titles (e.g. "Prüfung
fehlgeschlagen. WLAN prüfen.") still overflowed the screen edge at
size 1. Both now wrap the title across up to 3 lines via the new
wrapTitleLines() helper, and VIEW_TOP shifts down to match the actual
number of title lines instead of always assuming one.

checkForUpdate() now logs the same kind of diagnostic detail over
serial that performUpdate() already had (start info, and the specific
failure reason - HTTP error/status, JSON parse error, missing
tag_name, missing firmware.bin asset - instead of just returning
silently).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:56:37 +02:00

902 lines
43 KiB
C++

#include "menu_screen.h"
#include "touch_input.h"
#include "calibration_screen.h"
#include "wifi_manage_screen.h"
#include "stats_screen.h"
#include "stats_history_screen.h"
#include "logbook_files_screen.h"
#include "webui_screen.h"
#include "ota_update.h"
#include "location_presets_screen.h"
#include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h"
#include "aircraft_list_screen.h"
#include "about_screen.h"
#include "brightness_screen.h"
#include "timeout_screen.h"
#include "language_screen.h"
#include "units_screen.h"
#include "settings_backup.h"
#include "settings_store.h"
#include "menu_stars.h"
#include "i18n.h"
#include "config.h"
#include <time.h>
namespace MenuScreen {
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;
}
};
// ROW_GAP/ROW_START_Y bleiben unveraendert (werden von flightRowRect()
// weiter unten mitbenutzt, siehe FLIGHT_ROW_H).
constexpr int16_t ROW_GAP = 1;
constexpr int16_t ROW_START_Y = 18;
// Region-Unterseite (Sprache/Einheiten/Zurueck, nur 3 Eintraege):
// Zeilenhoehe/-abstand werden aus der tatsaechlich verfuegbaren
// Bildschirmflaeche errechnet (gleiches Muster wie bei SYSTEM_ROW_H
// weiter unten), statt die kleine, fuer volle Seiten (z.B.
// Flugoptionen: 14 Eintraege) gedachte feste Hoehe (vorher 22px) zu
// benutzen - die liess bei nur 3 Eintraegen fast den ganzen Bildschirm
// leer und machte die Buttons winzig und schwer zu treffen.
constexpr uint8_t REGION_ROW_COUNT = 3;
constexpr int16_t REGION_ROW_GAP = 10;
constexpr int16_t REGION_END_Y = Config::SCREEN_HEIGHT - 10;
constexpr int16_t REGION_ROW_H =
(REGION_END_Y - ROW_START_Y - (REGION_ROW_COUNT - 1) * REGION_ROW_GAP) / REGION_ROW_COUNT;
Rect rowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (REGION_ROW_H + REGION_ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), REGION_ROW_H};
}
constexpr int16_t CAT_ROW_H = 50;
constexpr int16_t CAT_ROW_GAP = 10;
constexpr int16_t CAT_START_Y = 30;
Rect catRowRect(uint8_t index) {
return {10, (int16_t)(CAT_START_Y + index * (CAT_ROW_H + CAT_ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), CAT_ROW_H};
}
// Eigene, etwas kompaktere Zeilenhoehe NUR fuer die Flugoptionen-Seite:
// die normale rowRect()-Hoehe (22px) liess bei 13 Eintraegen schon keinen
// Platz mehr fuer einen weiteren - andere Seiten (Region/System) bleiben
// bei der normalen Hoehe unveraendert, um dort nichts zu verschieben.
constexpr int16_t FLIGHT_ROW_H = 20;
Rect flightRowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (FLIGHT_ROW_H + ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), FLIGHT_ROW_H};
}
// Eigene, groesser berechnete Zeilenhoehe NUR fuer die System-Seite: mit
// nur 10 Eintraegen (vs. z.B. 14 auf der Flugoptionen-Seite) liess die
// normale rowRect()-Hoehe (22px) unten viel ungenutzten Platz frei. Die
// Hoehe wird hier stattdessen aus der tatsaechlich verfuegbaren
// Bildschirmflaeche errechnet, sodass die Buttons den Platz bis knapp
// ueber den Bildschirmrand ausfuellen - andere Seiten bleiben bei ihren
// eigenen, unveraenderten Zeilenhoehen.
// War 10, bevor "Sicherung"/"Wiederherstellen" in die neue
// "Sicherung & Reset"-Unterseite ausgelagert wurden (siehe
// BACKUP_RESET_ROW_COUNT unten) und dort durch einen einzelnen
// Ordner-Button ersetzt wurden: -2 (Backup/Restore raus) +1 (neuer
// Ordner-Button) = 9. Danach +1 fuer den neuen "Nach Update
// suchen"-Punkt (OTA-Update, siehe ota_update.h) = 10. Der
// Ruhebildschirm-Umschalter, der zwischenzeitlich hier eine eigene
// Zeile hatte, ist in den neuen Bildschirm-Timeout-Screen umgezogen
// (siehe timeout_screen.cpp) - Zeilenzahl bleibt dadurch bei 10.
constexpr uint8_t SYSTEM_ROW_COUNT = 10;
constexpr int16_t SYSTEM_ROW_GAP = 4;
constexpr int16_t SYSTEM_START_Y = 18;
constexpr int16_t SYSTEM_END_Y = Config::SCREEN_HEIGHT - 10;
constexpr int16_t SYSTEM_ROW_H =
(SYSTEM_END_Y - SYSTEM_START_Y - (SYSTEM_ROW_COUNT - 1) * SYSTEM_ROW_GAP) / SYSTEM_ROW_COUNT;
Rect systemRowRect(uint8_t index) {
return {10, (int16_t)(SYSTEM_START_Y + index * (SYSTEM_ROW_H + SYSTEM_ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), SYSTEM_ROW_H};
}
// Eigene Zeilenhoehe fuer die neue "Sicherung & Reset"-Unterseite (4
// Eintraege: Sichern/Wiederherstellen/Zuruecksetzen/Zurueck) - gleiches
// "aus verfuegbarem Platz errechnen"-Muster wie bei SYSTEM_ROW_H.
// ROW_START_Y wird mitbenutzt (siehe oben, gemeinsam mit rowRect()/
// flightRowRect()), nur GAP/COUNT/H sind eigene Werte.
constexpr uint8_t BACKUP_RESET_ROW_COUNT = 4;
constexpr int16_t BACKUP_RESET_ROW_GAP = 10;
constexpr int16_t BACKUP_RESET_END_Y = Config::SCREEN_HEIGHT - 10;
constexpr int16_t BACKUP_RESET_ROW_H =
(BACKUP_RESET_END_Y - ROW_START_Y - (BACKUP_RESET_ROW_COUNT - 1) * BACKUP_RESET_ROW_GAP) / BACKUP_RESET_ROW_COUNT;
Rect backupResetRowRect(uint8_t index) {
return {10, (int16_t)(ROW_START_Y + index * (BACKUP_RESET_ROW_H + BACKUP_RESET_ROW_GAP)),
(int16_t)(Config::SCREEN_WIDTH - 20), BACKUP_RESET_ROW_H};
}
void drawButton(TFT_eSPI& tft, const Rect& r, const String& label,
bool active = false, bool danger = false) {
uint16_t accent = danger ? TFT_RED : TFT_GREEN;
uint16_t bg = active ? accent : TFT_BLACK;
uint16_t fg = active ? TFT_BLACK : accent;
tft.fillRoundRect(r.x, r.y, r.w, r.h, 4, bg);
tft.drawRoundRect(r.x, r.y, r.w, r.h, 4, accent);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(fg, bg);
tft.drawString(label, r.x + r.w / 2, r.y + r.h / 2);
tft.setTextDatum(TL_DATUM);
}
String onOff(bool on) { return I18n::t(on ? StringId::ON : StringId::OFF); }
// Fortschrittspunkte-Anzeige waehrend SettingsBackup::backup()/restore()
// laufen (siehe Aufrufe unten in Page::BackupReset) - diese sind
// synchrone, SD-lastige Vorgaenge, die spuerbar dauern koennen und den
// Button vorher wie eingefroren wirken liessen. SettingsBackup ruft den
// hier uebergebenen Funktionszeiger vor jedem der beiden Kopiervorgaenge
// (erst Einstellungen, dann WLAN) auf. Namespace-globale Zeiger/Variablen
// statt Lambda-Capture, da ein einfacher C-Funktionszeiger uebergeben
// werden muss (kein std::function im Projekt).
TFT_eSPI* progressTft = nullptr;
Rect progressBtnRect;
String progressLabel;
uint8_t progressDots = 0;
void drawProgressStep() {
if (!progressTft) return;
progressDots++;
String label = progressLabel;
for (uint8_t i = 0; i < progressDots; i++) label += ".";
drawButton(*progressTft, progressBtnRect, label);
}
String screenTimeoutLabel(uint8_t minutes) {
String prefix = I18n::t(StringId::MENU_SCREEN_TIMEOUT_PREFIX);
if (minutes == 0) return prefix + I18n::t(StringId::NEVER);
return prefix + String(minutes) + " min";
}
String brightnessLabel(uint8_t percent) {
return String(I18n::t(StringId::MENU_BRIGHTNESS_PREFIX)) + String(percent) + "%";
}
void showBriefMessage(TFT_eSPI& tft, const String& msg, uint16_t color) {
tft.fillRect(0, Config::SCREEN_HEIGHT - 18, Config::SCREEN_WIDTH, 18, TFT_BLACK);
tft.setTextColor(color, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.drawString(msg, Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT - 9);
tft.setTextDatum(TL_DATUM);
delay(1200);
}
// Wie die einfache layoutWrapped()-Variante, aber mit optionalem
// Scroll-Offset und Sichtfenster (scrollY/viewTop/viewBottom) - Zeilen
// ausserhalb des Sichtfensters werden uebersprungen. Mit draw=false wird
// nur die Gesamthoehe berechnet, ohne etwas zu zeichnen (fuer die
// Scroll-Bedarfspruefung vorab).
int16_t layoutWrapped(TFT_eSPI& tft, int16_t x, int16_t startY, int16_t maxWidth,
int16_t lineHeight, const String& text, int16_t scrollY,
int16_t viewTop, int16_t viewBottom, bool draw) {
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);
}
if (draw) {
int16_t screenY = y - scrollY;
if (screenY >= viewTop && screenY <= viewBottom) {
tft.setCursor(x, screenY);
tft.print(line);
}
}
y += lineHeight;
start += line.length();
}
return y;
}
// Zerlegt einen Titel in bis zu maxLines Zeilen nach dem gleichen
// Wortumbruch-Prinzip wie layoutWrapped() - ein Titel wird aber nie
// gescrollt, sondern muss immer komplett sichtbar sein, daher eine
// eigene, einfachere Variante ohne Scroll-Unterstuetzung.
int wrapTitleLines(TFT_eSPI& tft, const String& text, int16_t maxWidth, String* outLines, int maxLines) {
int count = 0;
int32_t start = 0;
int32_t len = text.length();
while (start < len && count < maxLines) {
while (start < len && text[start] == ' ') start++;
if (start >= len) break;
String line = text.substring(start, len);
bool isLastAllowedLine = (count == maxLines - 1);
if (!isLastAllowedLine) {
while (tft.textWidth(line) > maxWidth) {
int32_t lastSpace = line.lastIndexOf(' ');
if (lastSpace <= 0) break;
line = line.substring(0, lastSpace);
}
}
outLines[count++] = line;
start += line.length();
}
if (count == 0) {
outLines[0] = text;
count = 1;
}
return count;
}
// Warn-Ueberlage, die praktisch den kompletten Bildschirm einnimmt (nur
// ein paar Pixel Rand) - urspruenglich nur fuers Einschalten des
// Flugbuchs gebaut (erklaert, warum es sich nach 24h automatisch
// wieder abschaltet), jetzt generisch mit uebergebenen Titel-/Text-
// StringIds, damit sie auch fuer die "Einstellungen zuruecksetzen"-
// Bestaetigung (Werksreset) wiederverwendet werden kann - beides sind
// seltene, potenziell folgenreiche Aktionen, die dieselbe deutliche
// Warnung verdienen. "Achtung!!!" (titleId) steht ganz oben, mit einer
// Leerzeile Abstand zum Fliesstext (bodyId) darunter; der Text
// scrollt bei Bedarf (laengere Uebersetzungen) ueber eigene Pfeil-
// Buttons, OK/Zurueck bleiben dabei immer unten fix und
// kollisionsfrei sichtbar. Sternchen laufen im Hintergrund mit, wie auf
// allen anderen Menue-Screens (nur der Radar-Screen selbst spart sich
// das wegen der CPU-Last durch Abfragen/Zeichnen). Gibt true zurueck,
// wenn "OK" angetippt wurde, false bei "Zurueck".
//
// title/body stehen VOR dem Aufruf per I18n::t() fest (statt StringIds
// entgegenzunehmen), damit auch dynamisch zusammengesetzte Texte (z.B.
// mit eingefuegter Versionsnummer bei OTA) moeglich sind. accentColor
// (Default TFT_RED fuer die beiden bestehenden, wirklich destruktiven
// Aufrufer) faerbt Rahmen und Titel - der OTA-Aufrufer uebergibt
// TFT_GREEN, da ein Update zwar bestaetigt werden sollte, aber keine
// "gefaehrliche" Loesch-Aktion wie Werksreset/Flugbuch ist.
bool confirmWarningScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor = TFT_RED) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X;
constexpr int16_t BOX_H = Config::SCREEN_HEIGHT - 2 * BOX_Y;
constexpr int16_t TEXT_MAX_WIDTH = BOX_W - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
// Titel-Text vorab in so viele Zeilen umbrechen, wie bei Größe 2
// (oder bei zu langem Text Größe 1) nötig sind - siehe
// wrapTitleLines() oben. VIEW_TOP hängt dadurch von der
// tatsächlichen Zeilenzahl des Titels ab, ist also kein constexpr
// mehr wie vorher (wo immer nur eine Titelzeile angenommen wurde).
tft.setTextSize(2);
uint8_t titleTextSize = 2;
if (tft.textWidth(title) > TEXT_MAX_WIDTH) {
tft.setTextSize(1);
titleTextSize = 1;
}
constexpr int MAX_TITLE_LINES = 3;
String titleLines[MAX_TITLE_LINES];
int titleLineCount = wrapTitleLines(tft, title, TEXT_MAX_WIDTH, titleLines, MAX_TITLE_LINES);
// Fließtext wird immer bei Größe 1 vermessen/gezeichnet (siehe
// layoutWrapped()-Aufrufe unten) - Größe hier zurücksetzen, falls
// obiger Titel-Breitentest sie auf 2 stehen gelassen hat, sonst
// würde die gleich folgende totalH-Berechnung (vor dem ersten
// redraw()) mit falscher (zu breiter) Schriftgröße rechnen.
tft.setTextSize(1);
// Eine Leerzeile Abstand zwischen Titel und Fließtext.
int16_t VIEW_TOP = (int16_t)(TITLE_Y + titleLineCount * LINE_H + 12);
constexpr int16_t BTN_H = 36;
constexpr int16_t BTN_GAP = 8;
constexpr int16_t BOTTOM_MARGIN = 8;
constexpr int16_t CANCEL_Y = BOX_Y + BOX_H - BOTTOM_MARGIN - BTN_H;
constexpr int16_t OK_Y = CANCEL_Y - BTN_GAP - BTN_H;
constexpr int16_t SCROLL_ROW_H = 28;
constexpr int16_t SCROLL_ROW_GAP = 8;
// Ohne Scroll-Pfeile verfuegbare Texthoehe zuerst pruefen - nur wenn
// der Text da nicht reinpasst, wird zusaetzlich Platz fuer die
// Pfeile reserviert (mehr Text-Platz bei kurzen Uebersetzungen).
constexpr int16_t VIEW_BOTTOM_NO_SCROLL = OK_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP;
int16_t totalH = layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, 0, 0, 0, false);
bool scrollable = (totalH - VIEW_BOTTOM_NO_SCROLL) > 0;
int16_t viewBottom = scrollable ? VIEW_BOTTOM_SCROLL : VIEW_BOTTOM_NO_SCROLL;
int16_t maxScroll = totalH - viewBottom;
if (maxScroll < 0) maxScroll = 0;
int16_t scrollY = 0;
constexpr int16_t SCROLL_STEP = 48;
Rect okBtn = {(int16_t)(BOX_X + 10), OK_Y, (int16_t)(BOX_W - 20), BTN_H};
Rect cancelBtn = {(int16_t)(BOX_X + 10), CANCEL_Y, (int16_t)(BOX_W - 20), BTN_H};
int16_t scrollRowY = VIEW_BOTTOM_SCROLL + SCROLL_ROW_GAP;
Rect upBtn = {(int16_t)(BOX_X + BOX_W / 2 - 64), scrollRowY, 60, SCROLL_ROW_H};
Rect downBtn = {(int16_t)(BOX_X + BOX_W / 2 + 4), scrollRowY, 60, SCROLL_ROW_H};
MenuStars::reset();
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, accentColor);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(accentColor, TFT_BLACK);
// Statische Titel ("Achtung!!!") sind kurz genug fuer Groesse 2,
// aber der OTA-Aufrufer baut den Titel dynamisch mit
// Versionsnummer zusammen (z.B. "Update verfuegbar: v3.5.0") -
// das passt bei Groesse 2 nicht mehr in die Box und lief vorher
// links/rechts ueber den Bildschirmrand hinaus. Deshalb hier
// die Breite bei Groesse 2 pruefen und bei Bedarf auf Groesse 1
// zurueckfallen, statt eine feste Groesse anzunehmen.
tft.setTextSize(titleTextSize);
for (int i = 0; i < titleLineCount; i++) {
tft.drawString(titleLines[i], BOX_X + BOX_W / 2, (int16_t)(TITLE_Y + i * LINE_H));
}
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, scrollY, VIEW_TOP, viewBottom, true);
drawButton(tft, okBtn, I18n::t(StringId::OK));
drawButton(tft, cancelBtn, I18n::t(StringId::BACK));
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (okBtn.contains(tap.x, tap.y)) return true;
if (cancelBtn.contains(tap.x, tap.y)) return false;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
}
}
// Einfacher Info-Screen mit nur EINEM Button (kein Abbrechen) - fuer
// Endzustaende, bei denen es nichts mehr zu entscheiden gibt, nur zu
// bestaetigen (z.B. Ergebnis eines OTA-Updates). Anders als
// showBriefMessage() (kurze Meldung unten am Bildschirmrand,
// verschwindet nach 1,2s automatisch von selbst) bleibt dieser Screen
// stehen, bis aktiv bestaetigt wird - wichtig bei sicherheitsrelevanten
// Meldungen wie einem fehlgeschlagenen oder erfolgreichen Firmware-
// Update, die der Nutzer auf keinen Fall verpassen darf. Gleicher
// Kasten-/Scroll-Aufbau wie confirmWarningScreen(), nur mit einem
// einzigen, ueber die volle Breite gehenden Button statt OK/Zurueck.
void infoScreen(TFT_eSPI& tft, const String& title, const String& body, uint16_t accentColor, const String& buttonLabel) {
constexpr int16_t BOX_X = 4;
constexpr int16_t BOX_Y = 4;
constexpr int16_t BOX_W = Config::SCREEN_WIDTH - 2 * BOX_X;
constexpr int16_t BOX_H = Config::SCREEN_HEIGHT - 2 * BOX_Y;
constexpr int16_t TEXT_MAX_WIDTH = BOX_W - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t TITLE_Y = BOX_Y + 16;
// Titel-Text vorab in so viele Zeilen umbrechen, wie bei Größe 2
// (oder bei zu langem Text Größe 1) nötig sind - siehe
// wrapTitleLines() oben. VIEW_TOP hängt dadurch von der
// tatsächlichen Zeilenzahl des Titels ab, ist also kein constexpr
// mehr wie vorher (wo immer nur eine Titelzeile angenommen wurde).
tft.setTextSize(2);
uint8_t titleTextSize = 2;
if (tft.textWidth(title) > TEXT_MAX_WIDTH) {
tft.setTextSize(1);
titleTextSize = 1;
}
constexpr int MAX_TITLE_LINES = 3;
String titleLines[MAX_TITLE_LINES];
int titleLineCount = wrapTitleLines(tft, title, TEXT_MAX_WIDTH, titleLines, MAX_TITLE_LINES);
// Fließtext wird immer bei Größe 1 vermessen/gezeichnet (siehe
// layoutWrapped()-Aufrufe unten) - Größe hier zurücksetzen, falls
// obiger Titel-Breitentest sie auf 2 stehen gelassen hat, sonst
// würde die gleich folgende totalH-Berechnung (vor dem ersten
// redraw()) mit falscher (zu breiter) Schriftgröße rechnen.
tft.setTextSize(1);
// Eine Leerzeile Abstand zwischen Titel und Fließtext.
int16_t VIEW_TOP = (int16_t)(TITLE_Y + titleLineCount * LINE_H + 12);
constexpr int16_t BTN_H = 40;
constexpr int16_t BOTTOM_MARGIN = 10;
constexpr int16_t BTN_Y = BOX_Y + BOX_H - BOTTOM_MARGIN - BTN_H;
constexpr int16_t SCROLL_ROW_H = 28;
constexpr int16_t SCROLL_ROW_GAP = 8;
constexpr int16_t VIEW_BOTTOM_NO_SCROLL = BTN_Y - 8;
constexpr int16_t VIEW_BOTTOM_SCROLL = VIEW_BOTTOM_NO_SCROLL - SCROLL_ROW_H - SCROLL_ROW_GAP;
int16_t totalH = layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, 0, 0, 0, false);
bool scrollable = (totalH - VIEW_BOTTOM_NO_SCROLL) > 0;
int16_t viewBottom = scrollable ? VIEW_BOTTOM_SCROLL : VIEW_BOTTOM_NO_SCROLL;
int16_t maxScroll = totalH - viewBottom;
if (maxScroll < 0) maxScroll = 0;
int16_t scrollY = 0;
constexpr int16_t SCROLL_STEP = 48;
Rect okBtn = {(int16_t)(BOX_X + 10), BTN_Y, (int16_t)(BOX_W - 20), BTN_H};
int16_t scrollRowY = VIEW_BOTTOM_SCROLL + SCROLL_ROW_GAP;
Rect upBtn = {(int16_t)(BOX_X + BOX_W / 2 - 64), scrollRowY, 60, SCROLL_ROW_H};
Rect downBtn = {(int16_t)(BOX_X + BOX_W / 2 + 4), scrollRowY, 60, SCROLL_ROW_H};
MenuStars::reset();
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.drawRoundRect(BOX_X, BOX_Y, BOX_W, BOX_H, 6, accentColor);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(accentColor, TFT_BLACK);
tft.setTextSize(titleTextSize);
for (int i = 0; i < titleLineCount; i++) {
tft.drawString(titleLines[i], BOX_X + BOX_W / 2, (int16_t)(TITLE_Y + i * LINE_H));
}
tft.setTextSize(1);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, BOX_X + 10, VIEW_TOP, TEXT_MAX_WIDTH, LINE_H, body, scrollY, VIEW_TOP, viewBottom, true);
drawButton(tft, okBtn, buttonLabel);
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (okBtn.contains(tap.x, tap.y)) return;
if (scrollable && upBtn.contains(tap.x, tap.y) && scrollY > 0) {
scrollY -= SCROLL_STEP;
if (scrollY < 0) scrollY = 0;
redraw();
} else if (scrollable && downBtn.contains(tap.x, tap.y) && scrollY < maxScroll) {
scrollY += SCROLL_STEP;
if (scrollY > maxScroll) scrollY = maxScroll;
redraw();
}
}
MenuStars::update(tft);
delay(20);
}
}
// Fortschrittsanzeige waehrend OtaUpdate::performUpdate() laeuft -
// gleiches Namespace-globale-Zeiger-Prinzip wie progressTft oben (siehe
// Settings-Backup-Fortschrittspunkte), da OtaUpdate::performUpdate()
// ebenfalls einen einfachen C-Funktionszeiger erwartet, keine
// Lambda-Capture erlaubt.
TFT_eSPI* otaProgressTft = nullptr;
void drawOtaProgress(uint8_t percent) {
if (!otaProgressTft) return;
TFT_eSPI& t = *otaProgressTft;
// Zwei Zeilen statt einer langen: der Praefix-Text
// (OTA_INSTALLING_PREFIX) ist in manchen Sprachen zu lang, um
// zusammen mit der Prozentzahl auf einer Zeile bei lesbarer
// Schriftgroesse zu passen (lief vorher links/rechts ueber den
// Bildschirmrand hinaus). Jetzt: Beschriftung klein oben, Prozent
// gross darunter.
constexpr int16_t BAND_H = 60;
int16_t cy = Config::SCREEN_HEIGHT / 2;
t.fillRect(0, (int16_t)(cy - BAND_H / 2), Config::SCREEN_WIDTH, BAND_H, TFT_BLACK);
t.setTextDatum(MC_DATUM);
t.setTextColor(TFT_GREEN, TFT_BLACK);
t.setTextSize(1);
t.drawString(I18n::t(StringId::OTA_INSTALLING_PREFIX), Config::SCREEN_WIDTH / 2, (int16_t)(cy - 14));
String percentLabel = String(percent) + "%";
t.setTextSize(3);
t.drawString(percentLabel, Config::SCREEN_WIDTH / 2, (int16_t)(cy + 12));
t.setTextSize(1);
t.setTextDatum(TL_DATUM);
}
// Kompletter Ablauf fuer "Nach Update suchen" (System-Menue) - Pruefung
// gegen GitHub-Releases, bei verfuegbarem Update explizite Bestaetigung
// (confirmWarningScreen() mit gruenem statt rotem Akzent - ein Update
// ist keine destruktive Aktion wie Werksreset, verdient aber trotzdem
// eine bewusste Bestaetigung, da WLAN/Strom waehrend des Vorgangs nicht
// unterbrochen werden sollten), danach Fortschrittsanzeige waehrend
// Download+Flash. WICHTIG: startet NICHT mehr automatisch neu und
// springt bei einem Fehler auch nicht einfach stillschweigend zurueck
// ins Menue - jedes Ergebnis (Erfolg wie Fehler) wird ueber infoScreen()
// als eigener, stehenbleibender Screen angezeigt, den der Nutzer aktiv
// bestaetigen muss. Bei Erfolg startet erst ein expliziter Tap auf
// "Jetzt neu starten" tatsaechlich neu.
void runOtaUpdateScreen(TFT_eSPI& tft) {
MenuStars::reset();
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawString(I18n::t(StringId::OTA_CHECKING), Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
tft.setTextDatum(TL_DATUM);
OtaUpdate::CheckInfo info = OtaUpdate::checkForUpdate();
if (info.result == OtaUpdate::CheckResult::Error) {
infoScreen(tft, I18n::t(StringId::OTA_CHECK_FAILED), "", TFT_RED, I18n::t(StringId::OK));
return;
}
if (info.result == OtaUpdate::CheckResult::UpToDate) {
String upToDateTitle = String(I18n::t(StringId::OTA_UP_TO_DATE_PREFIX)) + info.latestVersion;
infoScreen(tft, upToDateTitle, "", TFT_GREEN, I18n::t(StringId::OK));
return;
}
String title = String(I18n::t(StringId::OTA_UPDATE_AVAILABLE_PREFIX)) + info.latestVersion;
bool confirmed = confirmWarningScreen(tft, title, I18n::t(StringId::OTA_CONFIRM_BODY), TFT_GREEN);
if (!confirmed) return;
otaProgressTft = &tft;
tft.fillScreen(TFT_BLACK);
drawOtaProgress(0);
bool ok = OtaUpdate::performUpdate(info.downloadUrl, drawOtaProgress);
otaProgressTft = nullptr;
if (ok) {
// Bewusst KEIN automatischer Neustart mehr - der Nutzer
// bestaetigt aktiv per Button, damit er den Erfolg auch wirklich
// mitbekommt (vorher lief die Meldung nur 1,5s an, dann
// Neustart - leicht zu verpassen).
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_SUCCESS), I18n::t(StringId::OTA_SUCCESS_BODY),
TFT_GREEN, I18n::t(StringId::OTA_RESTART_BUTTON));
ESP.restart();
} else {
// Bewusst ein stehenbleibender Info-Screen statt der alten
// showBriefMessage() (1,2s, dann automatisch zurueck ins Menue)
// - ein fehlgeschlagenes Firmware-Update ist keine
// Nebensaechlichkeit, die man verpassen darf.
infoScreen(tft, I18n::t(StringId::OTA_UPDATE_FAILED), I18n::t(StringId::OTA_FAILED_BODY),
TFT_RED, I18n::t(StringId::OK));
}
}
enum class Page { Main, Region, System, Flight, BackupReset };
}
void run(TFT_eSPI& tft) {
Page page = Page::Main;
bool done = false;
MenuStars::reset();
while (!done) {
tft.fillScreen(TFT_BLACK);
if (page == Page::Main) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_SETTINGS));
Rect regionBtn = catRowRect(0);
Rect wifiBtn = catRowRect(1);
Rect systemBtn = catRowRect(2);
Rect flightBtn = catRowRect(3);
Rect backBtn = catRowRect(4);
drawButton(tft, regionBtn, I18n::t(StringId::MENU_CATEGORY_REGION));
drawButton(tft, wifiBtn, I18n::t(StringId::MENU_CATEGORY_WIFI));
drawButton(tft, systemBtn, I18n::t(StringId::MENU_CATEGORY_SYSTEM));
drawButton(tft, flightBtn, I18n::t(StringId::MENU_CATEGORY_FLIGHT));
drawButton(tft, backBtn, I18n::t(StringId::BACK));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (regionBtn.contains(tap.x, tap.y)) {
page = Page::Region;
} else if (wifiBtn.contains(tap.x, tap.y)) {
WifiManageScreen::run(tft);
} else if (systemBtn.contains(tap.x, tap.y)) {
page = Page::System;
} else if (flightBtn.contains(tap.x, tap.y)) {
page = Page::Flight;
} else if (backBtn.contains(tap.x, tap.y)) {
done = true;
}
} else if (page == Page::Region) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_CATEGORY_REGION));
Rect languageBtn = rowRect(0);
Rect unitsBtn = rowRect(1);
Rect backBtn = rowRect(2);
drawButton(tft, languageBtn, String(I18n::t(StringId::MENU_LANGUAGE)) + ": " + I18n::languageName(SettingsStore::language()));
drawButton(tft, unitsBtn, I18n::t(StringId::MENU_UNITS));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (languageBtn.contains(tap.x, tap.y)) {
LanguageScreen::run(tft);
} else if (unitsBtn.contains(tap.x, tap.y)) {
UnitsScreen::run(tft);
} else if (backBtn.contains(tap.x, tap.y)) {
page = Page::Main;
}
} else if (page == Page::System) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_CATEGORY_SYSTEM));
Rect calibBtn = systemRowRect(0);
Rect invertBtn = systemRowRect(1);
Rect brightnessBtn = systemRowRect(2);
Rect timeoutBtn = systemRowRect(3);
Rect nightDimBtn = systemRowRect(4);
Rect webuiBtn = systemRowRect(5);
// "Sicherung & Reset" (Backup/Restore/Werksreset, siehe
// Page::BackupReset unten) und "Nach Update suchen" stehen
// bewusst direkt ueber "Info", das seinerseits als letzter
// Punkt direkt ueber dem Zurueck-Button steht - so bleiben
// beide Positionen stabil, egal wie viele weitere Punkte davor
// noch dazukommen.
Rect backupResetBtn = systemRowRect(6);
Rect checkUpdateBtn = systemRowRect(7);
Rect aboutBtn = systemRowRect(8);
Rect backBtn = systemRowRect(9);
drawButton(tft, calibBtn, I18n::t(StringId::MENU_CALIBRATE));
String invertLabel = SettingsStore::displayInverted()
? I18n::t(StringId::MENU_DISPLAY_INVERTED)
: I18n::t(StringId::MENU_DISPLAY_NORMAL);
drawButton(tft, invertBtn, invertLabel);
drawButton(tft, brightnessBtn, brightnessLabel(SettingsStore::brightnessPercent()));
drawButton(tft, timeoutBtn, screenTimeoutLabel(SettingsStore::screenTimeoutMinutes()));
drawButton(tft, nightDimBtn, I18n::t(StringId::MENU_NIGHT_DIMMING) + onOff(SettingsStore::nightDimmingEnabled()));
drawButton(tft, webuiBtn, I18n::t(StringId::MENU_LOGBOOK_WEBUI));
drawButton(tft, backupResetBtn, I18n::t(StringId::MENU_BACKUP_RESET));
drawButton(tft, checkUpdateBtn, I18n::t(StringId::MENU_CHECK_UPDATE));
drawButton(tft, aboutBtn, I18n::t(StringId::MENU_ABOUT));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (calibBtn.contains(tap.x, tap.y)) {
CalibrationScreen::run(tft);
} else if (invertBtn.contains(tap.x, tap.y)) {
bool newState = !SettingsStore::displayInverted();
SettingsStore::setDisplayInverted(newState);
tft.invertDisplay(newState);
} else if (brightnessBtn.contains(tap.x, tap.y)) {
BrightnessScreen::run(tft);
} else if (timeoutBtn.contains(tap.x, tap.y)) {
// Vorher: Durchklicken per wiederholtem Antippen (0-10, ein
// Tipp pro Minute - bei z.B. 10 Minuten also zehn Tipps).
// Jetzt: eigener Screen mit Schieberegler, siehe
// timeout_screen.cpp - dort lebt jetzt auch der
// Ruhebildschirm-Umschalter (inhaltlich eng verwandt, und
// dort ist Platz fuer eine kurze Erklaerung).
TimeoutScreen::run(tft);
} else if (nightDimBtn.contains(tap.x, tap.y)) {
SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled());
} else if (backupResetBtn.contains(tap.x, tap.y)) {
page = Page::BackupReset;
} else if (checkUpdateBtn.contains(tap.x, tap.y)) {
runOtaUpdateScreen(tft);
} else if (aboutBtn.contains(tap.x, tap.y)) {
AboutScreen::run(tft);
} else if (webuiBtn.contains(tap.x, tap.y)) {
WebUiScreen::run(tft);
} else if (backBtn.contains(tap.x, tap.y)) {
page = Page::Main;
}
} else if (page == Page::BackupReset) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_BACKUP_RESET));
Rect backupBtn = backupResetRowRect(0);
Rect restoreBtn = backupResetRowRect(1);
Rect resetBtn = backupResetRowRect(2);
Rect backBtn = backupResetRowRect(3);
drawButton(tft, backupBtn, I18n::t(StringId::MENU_BACKUP));
drawButton(tft, restoreBtn, I18n::t(StringId::MENU_RESTORE));
// Danger-Akzent (rot) - deutlich von Sichern/Wiederherstellen
// abgesetzt, da diese Aktion (nach Bestaetigung) ALLE Daten
// unwiderruflich loescht, siehe confirmWarningScreen() unten.
drawButton(tft, resetBtn, I18n::t(StringId::MENU_FACTORY_RESET), false, true);
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (backupBtn.contains(tap.x, tap.y)) {
progressTft = &tft;
progressBtnRect = backupBtn;
progressLabel = I18n::t(StringId::MENU_BACKUP);
progressDots = 0;
bool ok = SettingsBackup::backup(drawProgressStep);
progressTft = nullptr;
showBriefMessage(tft, I18n::t(ok ? StringId::MENU_BACKUP_SAVED : StringId::MENU_BACKUP_FAILED),
ok ? TFT_GREEN : TFT_RED);
} else if (restoreBtn.contains(tap.x, tap.y)) {
if (SettingsBackup::hasBackup()) {
progressTft = &tft;
progressBtnRect = restoreBtn;
progressLabel = I18n::t(StringId::MENU_RESTORE);
progressDots = 0;
bool ok = SettingsBackup::restore(drawProgressStep);
progressTft = nullptr;
showBriefMessage(tft, I18n::t(ok ? StringId::MENU_RESTORED : StringId::MENU_RESTORE_FAILED),
ok ? TFT_GREEN : TFT_RED);
}
} else if (resetBtn.contains(tap.x, tap.y)) {
if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
I18n::t(StringId::MENU_FACTORY_RESET_WARNING_BODY))) {
tft.fillScreen(TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.drawString(I18n::t(StringId::MENU_FACTORY_RESET_DELETING),
Config::SCREEN_WIDTH / 2, Config::SCREEN_HEIGHT / 2);
tft.setTextDatum(TL_DATUM);
// Erfolgsfall: factoryReset() startet das Geraet neu und
// kehrt nie zurueck - dieser Code danach laeuft nur im
// (seltenen) Fehlerfall (SD nicht eingehaengt) ueberhaupt
// weiter.
bool ok = SettingsBackup::factoryReset();
if (!ok) {
showBriefMessage(tft, I18n::t(StringId::MENU_FACTORY_RESET_FAILED), TFT_RED);
}
}
} else if (backBtn.contains(tap.x, tap.y)) {
page = Page::System;
}
} else { // Page::Flight
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::MENU_CATEGORY_FLIGHT));
// Reine An/Aus-Schalter (Heartbeat, Notfall-Alarm, Naeherungs-LED,
// Flugbuch, Beobachtungsalarm, Bodenfahrzeuge-Filter) stehen
// bewusst gemeinsam am Ende der Liste, direkt ueber dem
// Zurueck-Button - zuerst die LED-gesteuerten Alarme (Heartbeat,
// Notfall, Naeherung; siehe LedAlert::Mode) zusammenhaengend,
// danach die beiden nicht-LED-basierten Schalter Flugbuch und
// Beobachtungsalarm sowie zuletzt der Bodenfahrzeuge-Filter.
Rect aircraftListBtn = flightRowRect(0);
Rect statsBtn = flightRowRect(1);
Rect statsHistoryBtn = flightRowRect(2);
Rect logFilesBtn = flightRowRect(3);
Rect locationBtn = flightRowRect(4);
Rect airlineBtn = flightRowRect(5);
Rect watchlistBtn = flightRowRect(6);
Rect heartbeatBtn = flightRowRect(7);
Rect emergencyBtn = flightRowRect(8);
Rect proximityBtn = flightRowRect(9);
Rect logbookBtn = flightRowRect(10);
Rect watchlistAlertBtn = flightRowRect(11);
Rect groundBtn = flightRowRect(12);
Rect backBtn = flightRowRect(13);
drawButton(tft, aircraftListBtn, I18n::t(StringId::MENU_AIRCRAFT_LIST));
drawButton(tft, statsBtn, I18n::t(StringId::MENU_STATISTICS));
drawButton(tft, statsHistoryBtn, I18n::t(StringId::MENU_STATS_HISTORY));
drawButton(tft, logFilesBtn, I18n::t(StringId::MENU_LOGBOOK_FILES));
drawButton(tft, locationBtn, I18n::t(StringId::MENU_LOCATION_PRESETS));
drawButton(tft, airlineBtn, I18n::t(StringId::MENU_AIRLINE_FILTER));
drawButton(tft, watchlistBtn, I18n::t(StringId::MENU_WATCHLIST));
drawButton(tft, heartbeatBtn, I18n::t(StringId::MENU_LED_HEARTBEAT) + onOff(SettingsStore::ledHeartbeatEnabled()));
drawButton(tft, emergencyBtn, I18n::t(StringId::MENU_EMERGENCY_ALERT) + onOff(SettingsStore::emergencyAlertEnabled()));
drawButton(tft, proximityBtn, I18n::t(StringId::MENU_PROXIMITY_LED) + onOff(SettingsStore::proximityAlertEnabled()));
drawButton(tft, logbookBtn, I18n::t(StringId::MENU_FLIGHT_LOGBOOK) + onOff(SettingsStore::flightLogbookEnabled()));
drawButton(tft, watchlistAlertBtn, I18n::t(StringId::MENU_WATCHLIST_ALERT) + onOff(SettingsStore::watchlistAlertEnabled()));
drawButton(tft, groundBtn, I18n::t(StringId::MENU_HIDE_GROUND) + onOff(SettingsStore::hideGroundVehicles()));
drawButton(tft, backBtn, I18n::t(StringId::BACK_ARROW));
TouchInput::Point tap;
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (aircraftListBtn.contains(tap.x, tap.y)) {
if (AircraftListScreen::run(tft)) {
// Ein Flugzeug wurde in der Liste ausgewaehlt - direkt bis
// zum Radar zurueckspringen (mit offenem Detail-Panel),
// statt in der Flugoptionen-Seite stehen zu bleiben.
done = true;
}
} else if (statsBtn.contains(tap.x, tap.y)) {
StatsScreen::run(tft);
} else if (statsHistoryBtn.contains(tap.x, tap.y)) {
StatsHistoryScreen::run(tft);
} else if (logFilesBtn.contains(tap.x, tap.y)) {
LogbookFilesScreen::run(tft);
} else if (locationBtn.contains(tap.x, tap.y)) {
LocationPresetsScreen::run(tft);
} else if (airlineBtn.contains(tap.x, tap.y)) {
AirlineFilterScreen::run(tft);
} else if (watchlistBtn.contains(tap.x, tap.y)) {
AircraftWatchlistScreen::run(tft);
} else if (heartbeatBtn.contains(tap.x, tap.y)) {
SettingsStore::setLedHeartbeatEnabled(!SettingsStore::ledHeartbeatEnabled());
} else if (emergencyBtn.contains(tap.x, tap.y)) {
SettingsStore::setEmergencyAlertEnabled(!SettingsStore::emergencyAlertEnabled());
} else if (proximityBtn.contains(tap.x, tap.y)) {
SettingsStore::setProximityAlertEnabled(!SettingsStore::proximityAlertEnabled());
} else if (logbookBtn.contains(tap.x, tap.y)) {
if (SettingsStore::flightLogbookEnabled()) {
// Ausschalten ist immer unbedenklich - keine Bestaetigung noetig.
SettingsStore::setFlightLogbookEnabled(false);
SettingsStore::setFlightLogbookEnabledAtEpoch(0);
SettingsStore::setFlightLogbookSessionFile("");
} else if (confirmWarningScreen(tft, I18n::t(StringId::MENU_LOGBOOK_WARNING_TITLE),
I18n::t(StringId::MENU_LOGBOOK_WARNING_BODY))) {
SettingsStore::setFlightLogbookEnabled(true);
SettingsStore::setFlightLogbookEnabledAtEpoch((uint32_t)time(nullptr));
// Leerer Eintrag erzwingt eine frische Sitzungsdatei beim
// naechsten FlightLogbook::update() statt eine evtl. noch
// vorhandene alte Datei weiterzuschreiben.
SettingsStore::setFlightLogbookSessionFile("");
}
} else if (watchlistAlertBtn.contains(tap.x, tap.y)) {
SettingsStore::setWatchlistAlertEnabled(!SettingsStore::watchlistAlertEnabled());
} else if (groundBtn.contains(tap.x, tap.y)) {
SettingsStore::setHideGroundVehicles(!SettingsStore::hideGroundVehicles());
} else if (backBtn.contains(tap.x, tap.y)) {
page = Page::Main;
}
}
}
}
}