Files
flyradar/src/menu_screen.cpp

907 lines
42 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 "net_task.h"
#include "location_presets_screen.h"
#include "airline_filter_screen.h"
#include "aircraft_watchlist_screen.h"
#include "aircraft_list_screen.h"
#include "route_filter_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 remain unchanged (also used by flightRowRect()
// below, see FLIGHT_ROW_H).
constexpr int16_t ROW_GAP = 1;
constexpr int16_t ROW_START_Y = 18;
// Region sub-page (Language/Units/Back, only 3 entries): row
// height/spacing calculated from actual available screen area (same
// pattern as SYSTEM_ROW_H below), instead of the small fixed height
// (formerly 22px) intended for full pages (e.g. Flight Options: 14
// entries) - that left nearly the whole screen empty with only 3
// entries and made buttons tiny and hard to hit.
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};
}
// Custom, slightly more compact row height ONLY for the Flight Options
// page: the normal rowRect() height (22px) already left no room for
// another entry with 13 entries - other pages (Region/System) keep
// their normal height unchanged to avoid shifting anything there.
constexpr int16_t FLIGHT_ROW_H = 19;
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};
}
// Custom, larger calculated row height ONLY for the System page: with
// only 10 entries (vs. e.g. 14 on the Flight Options page), the
// normal rowRect() height (22px) left a lot of unused space at the
// bottom. Height is instead calculated from actual available screen
// area so buttons fill the space down to just above the screen
// edge - other pages keep their own unchanged row heights.
// Was 10 before "Backup"/"Restore" were moved to the new
// "Backup & Reset" sub-page (see BACKUP_RESET_ROW_COUNT below) and
// replaced there by a single folder button: -2 (Backup/Restore
// removed) +1 (new folder button) = 9. Then +1 for the new
// "Check for update" item (OTA update, see ota_update.h) = 10. The
// screensaver toggle, which temporarily had its own row here, moved
// to the new screen timeout screen (see timeout_screen.cpp) - row
// count therefore stays at 10.
constexpr uint8_t SYSTEM_ROW_COUNT = 11; // +1 for Eco-Mode
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};
}
// Custom row height for the new "Backup & Reset" sub-page (4
// entries: Backup/Restore/Factory Reset/Back) - same "calculate from
// available space" pattern as SYSTEM_ROW_H.
// ROW_START_Y is reused (see above, shared with rowRect()/
// flightRowRect()), only GAP/COUNT/H are custom values.
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); }
// Progress dots display while SettingsBackup::backup()/restore()
// run (see calls below in Page::BackupReset) - these are
// synchronous, SD-intensive operations that can take noticeable
// time and previously made the button appear frozen.
// SettingsBackup calls the function pointer passed here before each
// of the two copy operations (first settings, then WiFi).
// Namespace-global pointers/variables instead of lambda capture,
// because a plain C function pointer must be passed (no
// std::function in the project).
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);
}
// Like the simple layoutWrapped() variant, but with optional scroll
// offset and viewport (scrollY/viewTop/viewBottom) - lines outside
// the viewport are skipped. With draw=false, only the total height
// is calculated without drawing anything (for pre-checking scroll
// need).
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;
}
// Splits a title into up to maxLines lines using the same word-wrapping
// principle as layoutWrapped() - but a title is never scrolled and must
// always be fully visible, hence a separate, simpler variant without
// scroll support.
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;
}
// Warning overlay that covers nearly the entire screen (just a few
// pixels margin) - originally built only for enabling the flight
// logbook (explains why it auto-disables after 24h), now generic
// with passed-in title/text to also be reusable for the "Reset
// settings" confirmation (factory reset) - both are rare, potentially
// consequential actions that deserve the same clear warning.
// "Attention!!!" (titleId) appears at the top, with a blank line
// before the body text (bodyId) below; the text scrolls if needed
// (longer translations) via dedicated arrow buttons, OK/Back always
// stay fixed at the bottom and collision-free visible. Stars run in
// the background, as on all other menu screens (only the radar
// screen skips them due to CPU load from polling/drawing). Returns
// true if "OK" was tapped, false for "Back".
//
// title/body are fixed BEFORE the call via I18n::t() (instead of
// accepting StringIds), so dynamically composed texts (e.g. with
// inserted version number for OTA) are possible. accentColor
// (default TFT_RED for the two existing, truly destructive callers)
// colors frame and title - the OTA caller passes TFT_GREEN, since an
// update should be confirmed but is not a "dangerous" deletion
// action like factory reset/flight logbook.
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;
// Wrap title text into as many lines as needed at size 2 (or size 1
// if text is too long) - see wrapTitleLines() above. VIEW_TOP
// therefore depends on the actual number of title lines, so it is
// no longer constexpr as before (where only one title line was
// assumed).
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);
// Body text is always measured/drawn at size 1 (see
// layoutWrapped() calls below) - reset size here in case the
// title width check above left it at 2, otherwise the
// immediately following totalH calculation (before the first
// redraw()) would use the wrong (too wide) font size.
tft.setTextSize(1);
// One blank line between title and body 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;
// First check available text height without scroll arrows - only if
// the text does not fit, additional space for arrows is reserved
// (more text space for short translations).
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);
// Static titles ("Attention!!!") are short enough for size 2, but
// the OTA caller builds the title dynamically with a version
// number (e.g. "Update available: v3.5.0") - this no longer
// fits in the box at size 2 and previously overflowed left/right
// beyond the screen edge. Hence check width at size 2 and fall
// back to size 1 if needed, instead of assuming a fixed size.
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);
}
}
// Simple info screen with only ONE button (no cancel) - for
// terminal states where there is nothing left to decide, only to
// acknowledge (e.g. result of an OTA update). Unlike
// showBriefMessage() (short message at the bottom of the screen,
// auto-disappears after 1.2s), this screen stays until actively
// acknowledged - important for safety-relevant messages like a
// failed or successful firmware update that the user must not miss.
// Same box/scroll layout as confirmWarningScreen(), but with a
// single full-width button instead of OK/Back.
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;
// Wrap title text into as many lines as needed at size 2 (or size 1
// if text is too long) - see wrapTitleLines() above. VIEW_TOP
// therefore depends on the actual number of title lines, so it is
// no longer constexpr as before (where only one title line was
// assumed).
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);
// Body text is always measured/drawn at size 1 (see
// layoutWrapped() calls below) - reset size here in case the
// title width check above left it at 2, otherwise the
// immediately following totalH calculation (before the first
// redraw()) would use the wrong (too wide) font size.
tft.setTextSize(1);
// One blank line between title and body 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);
}
}
// Progress display while OtaUpdate::performUpdate() runs - same
// namespace-global pointer principle as progressTft above (see
// Settings-Backup progress dots), since OtaUpdate::performUpdate()
// also expects a plain C function pointer, no lambda capture
// allowed.
TFT_eSPI* otaProgressTft = nullptr;
void drawOtaProgress(uint8_t percent) {
if (!otaProgressTft) return;
TFT_eSPI& t = *otaProgressTft;
// Two lines instead of one long one: the prefix text
// (OTA_INSTALLING_PREFIX) is too long in some languages to fit
// together with the percentage on one line at a readable font size
// (previously overflowed left/right beyond the screen edge).
// Now: label small at top, percentage large below.
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);
}
// Complete flow for "Check for update" (System menu) - checks
// against GitHub releases, explicit confirmation when update is
// available (confirmWarningScreen() with green instead of red
// accent - an update is not a destructive action like factory reset,
// but still deserves conscious confirmation since WiFi/power should
// not be interrupted during the process), then progress display
// during download+flash. IMPORTANT: no longer auto-restarts and no
// longer silently jumps back to the menu on error - every result
// (success as well as failure) is shown via infoScreen() as its own
// persistent screen that the user must actively acknowledge. On
// success, only an explicit tap on "Restart now" actually restarts.
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);
NetTask::pause();
OtaUpdate::CheckInfo info = OtaUpdate::checkForUpdate();
NetTask::resume();
if (info.result == OtaUpdate::CheckResult::Error) {
infoScreen(tft, I18n::t(StringId::OTA_CHECK_FAILED), info.errorMsg, 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);
NetTask::pause();
bool ok = OtaUpdate::performUpdate(info.downloadUrl, drawOtaProgress);
NetTask::resume();
otaProgressTft = nullptr;
if (ok) {
// Deliberately NO automatic restart anymore - the user
// actively confirms via button so they actually notice the
// success (previously the message showed for only 1.5s,
// then restart - easy to miss).
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 {
// Deliberately a persistent info screen instead of the old
// showBriefMessage() (1.2s, then automatically back to menu)
// - a failed firmware update is not a minor detail that one
// can afford to miss.
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(6);
// "Backup & Reset" (Backup/Restore/Factory Reset, see
// Page::BackupReset below) and "Check for update" are
// deliberately placed directly above "About", which in turn
// is the last item directly above the Back button - this
// keeps both positions stable regardless of how many further
// items come before them.
Rect ecoModeBtn = systemRowRect(5);
Rect backupResetBtn = systemRowRect(7);
Rect checkUpdateBtn = systemRowRect(8);
Rect aboutBtn = systemRowRect(9);
Rect backBtn = systemRowRect(10);
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, ecoModeBtn, I18n::t(StringId::MENU_ECO_MODE) + onOff(SettingsStore::ecoModeEnabled()));
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)) {
// Previously: cycling through values by repeated tapping
// (0-10, one tap per minute - e.g. 10 taps for 10 minutes).
// Now: dedicated screen with a slider, see
// timeout_screen.cpp - the screensaver toggle now also
// lives there (closely related in content, and there is
// room for a brief explanation).
TimeoutScreen::run(tft);
} else if (nightDimBtn.contains(tap.x, tap.y)) {
SettingsStore::setNightDimmingEnabled(!SettingsStore::nightDimmingEnabled());
} else if (ecoModeBtn.contains(tap.x, tap.y)) {
SettingsStore::setEcoModeEnabled(!SettingsStore::ecoModeEnabled());
} else if (webuiBtn.contains(tap.x, tap.y)) {
WebUiScreen::run(tft);
} 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 (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 accent (red) - clearly distinguished from Backup/Restore
// since this action (after confirmation) irreversibly DELETES
// ALL data, see confirmWarningScreen() below.
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);
// On success: factoryReset() restarts the device and never
// returns - this code after it only continues in the
// (rare) error case (SD not mounted).
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));
// Pure on/off toggles (Heartbeat, Emergency Alert, Proximity LED,
// Flight Logbook, Watchlist Alert, Ground Vehicles Filter) are
// deliberately grouped together at the end of the list,
// directly above the Back button - first the LED-driven alerts
// (Heartbeat, Emergency, Proximity; see LedAlert::Mode)
// together, then the two non-LED-based toggles Flight Logbook
// and Watchlist Alert, and finally the Ground Vehicles 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 routeFilterBtn = flightRowRect(7);
Rect heartbeatBtn = flightRowRect(8);
Rect emergencyBtn = flightRowRect(9);
Rect proximityBtn = flightRowRect(10);
Rect logbookBtn = flightRowRect(11);
Rect watchlistAlertBtn = flightRowRect(12);
Rect groundBtn = flightRowRect(13);
Rect backBtn = flightRowRect(14);
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, routeFilterBtn, I18n::t(StringId::MENU_ROUTE_FILTER));
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)) {
// An aircraft was selected in the list - jump directly back
// to the radar (with detail panel open), instead of
// staying on the Flight Options page.
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 (routeFilterBtn.contains(tap.x, tap.y)) {
RouteFilterScreen::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()) {
// Turning off is always harmless - no confirmation needed.
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));
// Empty entry forces a fresh session file on the next
// FlightLogbook::update() instead of continuing to
// write to a possibly existing old file.
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;
}
}
}
}
}