#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 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; } } } } }