v2.5.0: History chart, web export, and marquee fix for nearest airport

Adds a 7-day bar-chart view of the flight logbook (Menu -> Flight
Options -> History chart), and a small built-in web server that
serves a logbook overview page plus a merged CSV export of all
logged days once the device is connected to WiFi (discoverable via
the "?" button on the Logbook files screen, which now also shows the
live device IP). Also fixes the "nearest airport" line on the
Location Presets screen, which previously used tft.setViewport() and
went completely invisible with our custom font - it now scrolls as a
proper marquee without relying on the viewport clip.

Also documents the standard release workflow in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-06 05:49:23 +02:00
parent 8f557fc829
commit a271109f19
18 changed files with 690 additions and 60 deletions

View File

@@ -4,6 +4,7 @@
#include "menu_stars.h"
#include "config.h"
#include "i18n.h"
#include <WiFi.h>
namespace LogbookFilesScreen {
@@ -26,6 +27,118 @@ namespace {
constexpr uint8_t MAX_DAYS_QUERIED = 31;
constexpr uint8_t VISIBLE_ROWS = 10;
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;
}
void runInfoScreen(TFT_eSPI& tft) {
MenuStars::reset();
constexpr int16_t textMaxWidth = Config::SCREEN_WIDTH - 20;
constexpr int16_t LINE_H = 16;
constexpr int16_t VIEW_TOP = 36;
constexpr int16_t VIEW_BOTTOM = Config::SCREEN_HEIGHT - 60;
bool wifiConnected = WiFi.status() == WL_CONNECTED;
String urlLine = wifiConnected ? ("http://" + WiFi.localIP().toString() + "/") : String();
int16_t totalH = VIEW_TOP;
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_INFO_PARA1), 0, 0, 0, false);
totalH += 8;
if (wifiConnected) {
totalH += LINE_H;
} else {
totalH = layoutWrapped(tft, 10, totalH, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_INFO_PARA2), 0, 0, 0, false);
}
int16_t maxScroll = totalH - VIEW_BOTTOM;
if (maxScroll < 0) maxScroll = 0;
bool scrollable = maxScroll > 0;
int16_t scrollY = 0;
Rect backBtn = scrollable
? Rect{10, (int16_t)(Config::SCREEN_HEIGHT - 50), 130, 40}
: Rect{10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
Rect upBtn = {146, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40};
Rect downBtn = {190, (int16_t)(Config::SCREEN_HEIGHT - 50), 38, 40};
constexpr int16_t SCROLL_STEP = 48;
auto redraw = [&]() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::LOGFILES_INFO_TITLE));
tft.setTextColor(TFT_GREEN, TFT_BLACK);
int16_t y = VIEW_TOP;
y = layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_INFO_PARA1), scrollY, VIEW_TOP, VIEW_BOTTOM, true);
y += 8;
if (wifiConnected) {
int16_t screenY = y - scrollY;
if (screenY >= VIEW_TOP && screenY <= VIEW_BOTTOM) {
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, screenY);
tft.print(urlLine);
}
} else {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
layoutWrapped(tft, 10, y, textMaxWidth, LINE_H, I18n::t(StringId::LOGFILES_INFO_PARA2), scrollY, VIEW_TOP, VIEW_BOTTOM, true);
}
drawButton(tft, backBtn, I18n::t(StringId::BACK));
if (scrollable) {
drawButton(tft, upBtn, "^");
drawButton(tft, downBtn, "v");
}
};
redraw();
while (true) {
TouchInput::Point tap;
if (TouchInput::wasTapped(tap)) {
if (backBtn.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);
}
}
}
void run(TFT_eSPI& tft) {
@@ -40,52 +153,60 @@ void run(TFT_eSPI& tft) {
FlightLogbook::DayEntry days[MAX_DAYS_QUERIED];
uint8_t count = FlightLogbook::listDays(days, MAX_DAYS_QUERIED);
Rect infoBtn = {(int16_t)(Config::SCREEN_WIDTH - 40), 2, 30, 24};
Rect backBtn = {10, (int16_t)(Config::SCREEN_HEIGHT - 50), (int16_t)(Config::SCREEN_WIDTH - 20), 40};
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::LOGFILES_TITLE));
if (count == 0) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, 40);
tft.println(I18n::t(StringId::LOGFILES_EMPTY));
} else {
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
int16_t y = 30;
if (count > VISIBLE_ROWS) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, y);
tft.print(String(I18n::t(StringId::LOGFILES_SHOWING_PREFIX)) + VISIBLE_ROWS +
I18n::t(StringId::LOGFILES_OF) + count + I18n::t(StringId::LOGFILES_DAYS_SUFFIX));
y += 16;
}
for (uint8_t i = startIdx; i < count; i++) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, y);
tft.print(days[i].date);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(110, y);
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX));
y += 20;
}
}
drawButton(tft, backBtn, I18n::t(StringId::BACK));
MenuStars::reset();
bool done = false;
MenuStars::reset();
while (!done) {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 14);
tft.println(I18n::t(StringId::LOGFILES_TITLE));
drawButton(tft, infoBtn, "?");
if (count == 0) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, 40);
tft.println(I18n::t(StringId::LOGFILES_EMPTY));
} else {
uint8_t startIdx = (count > VISIBLE_ROWS) ? (count - VISIBLE_ROWS) : 0;
int16_t y = 30;
if (count > VISIBLE_ROWS) {
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setCursor(10, y);
tft.print(String(I18n::t(StringId::LOGFILES_SHOWING_PREFIX)) + VISIBLE_ROWS +
I18n::t(StringId::LOGFILES_OF) + count + I18n::t(StringId::LOGFILES_DAYS_SUFFIX));
y += 16;
}
for (uint8_t i = startIdx; i < count; i++) {
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, y);
tft.print(days[i].date);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(110, y);
tft.print(String(days[i].count) + I18n::t(StringId::LOGFILES_AIRCRAFT_SUFFIX));
y += 20;
}
}
drawButton(tft, backBtn, I18n::t(StringId::BACK));
TouchInput::Point tap;
if (TouchInput::wasTapped(tap) && backBtn.contains(tap.x, tap.y)) {
while (true) {
if (TouchInput::wasTapped(tap)) break;
MenuStars::update(tft);
delay(20);
}
if (infoBtn.contains(tap.x, tap.y)) {
runInfoScreen(tft);
} else if (backBtn.contains(tap.x, tap.y)) {
done = true;
}
MenuStars::update(tft);
delay(20);
}
}
}
}