v2.7.0: Time-of-day greeting, bearing indicator, and empty-sky timer

Three small usability additions:
- Splash screen now shows a "Good morning/day/evening" greeting under
  the title, based on local time (computed after the timezone offset
  from IP geolocation is applied via configTime(), not before, so it
  reflects actual local time rather than UTC).
- Selecting an aircraft on the radar now also draws a dotted bearing
  line plus a degree label from the radar center to the compass edge,
  showing which direction to actually look to spot it in the sky
  (distinct from the aircraft's own heading arrow).
- The radar info bar now shows "Empty sky for <time>" instead of the
  "tap for details" hint whenever no aircraft are currently in range
  after all filters, counting up from the last sighting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eiswolf-BG
2026-08-06 20:41:09 +02:00
parent e4aa03dbf2
commit 9b9b89eb85
12 changed files with 141 additions and 1 deletions

View File

@@ -19,6 +19,9 @@ enum class StringId : uint8_t {
SPLASH_WIFI_FAILED,
SPLASH_GETTING_LOCATION,
SPLASH_READY,
SPLASH_GREETING_MORNING,
SPLASH_GREETING_DAY,
SPLASH_GREETING_EVENING,
SD_REQUIRED_LINE1,
SD_REQUIRED_LINE2,
@@ -134,6 +137,7 @@ enum class StringId : uint8_t {
LOGFILES_INFO_PARA2,
RADAR_TAP_FOR_DETAILS,
RADAR_EMPTY_SKY_PREFIX,
DETAIL_MODEL,
DETAIL_TYPE,

View File

@@ -19,6 +19,9 @@ static const char* const I18N_DE[] = {
"WLAN FEHLGESCHLAGEN",
"Ermittle Standort...",
"Bereit!",
"Guten Morgen",
"Guten Tag",
"Guten Abend",
"Für diese App wird",
"eine SD-Karte",
@@ -134,6 +137,7 @@ static const char* const I18N_DE[] = {
"Keine WLAN-Verbindung - die Export-Seite ist nur erreichbar, solange eine Netzwerkverbindung besteht.",
"Für Details antippen",
"Leerer Himmel seit ",
"Modell: ",
"Typ: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_EN[] = {
"WiFi FAILED",
"Getting location...",
"Ready!",
"Good morning",
"Good day",
"Good evening",
"For this app a",
"SD card is",
@@ -135,6 +138,7 @@ static const char* const I18N_EN[] = {
"No WiFi connection - the export page is only reachable while connected to a network.",
"Tap for details",
"Empty sky for ",
"Model: ",
"Type: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_ES[] = {
"WiFi FALLÓ",
"Obteniendo ubicación...",
"¡Listo!",
"Buenos días",
"Buenas tardes",
"Buenas noches",
"Esta app necesita",
"una tarjeta SD",
@@ -134,6 +137,7 @@ static const char* const I18N_ES[] = {
"Sin conexión WiFi - la página de exportación solo es accesible mientras haya una conexión de red.",
"Toque para ver detalles",
"Cielo vacío desde hace ",
"Modelo: ",
"Tipo: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_FR[] = {
"ÉCHEC WiFi",
"Localisation...",
"Prêt !",
"Bonjour",
"Bonjour",
"Bonsoir",
"Pour cette appli une",
"carte SD est",
@@ -134,6 +137,7 @@ static const char* const I18N_FR[] = {
"Pas de connexion WiFi - la page d'export n'est accessible que lorsqu'une connexion réseau est active.",
"Touchez pour les détails",
"Ciel vide depuis ",
"Modèle : ",
"Type : ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_IT[] = {
"WiFi FALLITO",
"Rilevamento posizione...",
"Pronto!",
"Buongiorno",
"Buon pomeriggio",
"Buonasera",
"Questa app richiede",
"una scheda SD",
@@ -134,6 +137,7 @@ static const char* const I18N_IT[] = {
"Nessuna connessione WiFi - la pagina di esportazione è raggiungibile solo quando è attiva una connessione di rete.",
"Tocca per i dettagli",
"Cielo vuoto da ",
"Modello: ",
"Tipo: ",

View File

@@ -19,6 +19,9 @@ static const char* const I18N_TR[] = {
"WiFi BAŞARISIZ",
"Konum alınıyor...",
"Hazır!",
"Günaydın",
"İyi günler",
"İyi akşamlar",
"Bu uygulama için bir",
"SD kart",
@@ -134,6 +137,7 @@ static const char* const I18N_TR[] = {
"WiFi bağlantısı yok - dışa aktarma sayfasına yalnızca bir ağ bağlantısı varken ulaşılabilir.",
"Detaylar için dokunun",
"Boş gökyüzü: ",
"Model: ",
"Tip: ",

View File

@@ -318,10 +318,19 @@ void setup() {
}
SplashScreen::setStatusLine(tft, 2, I18n::t(StringId::SPLASH_READY));
// Sobald die IP-Geolocation einen UTC-Offset geliefert hat, die
// Zeitzone entsprechend setzen - Zeitstempel (Flugbuch, Log-Dateinamen)
// zeigen dann die ECHTE Ortszeit statt UTC, automatisch weltweit richtig.
if (LocationManager::hasUtcOffset()) {
configTime(LocationManager::utcOffsetSeconds(), 0, "pool.ntp.org", "time.nist.gov");
}
// ERST JETZT die Begruessung zeichnen - vorher (siehe oben) war die
// Zeitzone noch nicht gesetzt, configTime() lief noch mit UTC statt
// Ortszeit, was in vielen Zeitzonen zu einer falschen Tageszeit-
// Einschaetzung gefuehrt haette (z.B. UTC-Nachmittag = Ortszeit-Abend).
SplashScreen::showGreeting(tft);
AircraftTable::init();
AirlineLookup::init();
FlightLogbook::init();

View File

@@ -90,6 +90,50 @@ namespace {
return TFT_RED;
}
// Zeigt die PEILUNG (nicht zu verwechseln mit der Flugrichtung/heading)
// zum aktuell ausgewaehlten Flugzeug: eine duenne, gepunktete Linie vom
// Radar-Zentrum zum Kreisrand in Richtung bearingDeg, plus die Gradzahl
// direkt ausserhalb des Rings. Hilft dabei, das Flugzeug tatsaechlich am
// Himmel zu finden ("in diese Richtung schauen"). Nur fuer den Moment
// des Zeichnens relevant - beim naechsten Sweep-Tick/Redraw wird sie vom
// normalen Hintergrund-Redraw automatisch mit geloescht und neu gesetzt.
void drawBearingIndicator(TFT_eSPI& gfx, const Layout& L, float bearingDeg) {
double rad = bearingDeg * PI / 180.0;
double s = sin(rad), c = cos(rad);
// Gepunktete Linie: kurze Segmente statt einer durchgezogenen Linie,
// damit sie sich optisch von den Kompass-Kreuzlinien und Ringen
// unterscheidet und nicht wie ein staendiges UI-Element wirkt.
constexpr uint8_t DOT_COUNT = 10;
for (uint8_t i = 0; i < DOT_COUNT; i++) {
if (i % 2 != 0) continue; // nur jedes zweite Segment zeichnen
float t0 = (float)i / DOT_COUNT;
float t1 = (float)(i + 1) / DOT_COUNT;
int16_t x0 = L.cx + (int16_t)lround(t0 * L.radius * s);
int16_t y0 = L.cy - (int16_t)lround(t0 * L.radius * c);
int16_t x1 = L.cx + (int16_t)lround(t1 * L.radius * s);
int16_t y1 = L.cy - (int16_t)lround(t1 * L.radius * c);
gfx.drawLine(x0, y0, x1, y1, TFT_WHITE);
}
// Gradzahl knapp ausserhalb des Rings, an der Stelle wo die Peilung
// den Kreisrand durchstoesst. Der Radius-Kreis nutzt fast die volle
// Bildschirmbreite (siehe computeLayout(): nur 6px Rand) - ein Label
// ausserhalb des Rings wuerde bei Ost/West-Peilungen ueber den
// sichtbaren Bereich hinausragen. Deshalb: Label-Position an die
// Bildschirmgrenzen clampen statt stur dem Winkel zu folgen.
int16_t labelX = L.cx + (int16_t)lround((L.radius + 10) * s);
int16_t labelY = L.cy - (int16_t)lround((L.radius + 10) * c);
labelX = constrain(labelX, (int16_t)14, (int16_t)(Config::SCREEN_WIDTH - 14));
labelY = constrain(labelY, (int16_t)10, (int16_t)(L.cy + L.radius + 8));
char buf[6];
snprintf(buf, sizeof(buf), "%.0f", bearingDeg);
gfx.setTextDatum(MC_DATUM);
gfx.setTextColor(TFT_WHITE, TFT_BLACK);
gfx.drawString(buf, labelX, labelY);
gfx.setTextDatum(TL_DATUM);
}
// kurzer Kursstrich in Flugrichtung (headingDeg) MIT Pfeilspitze am Ende -
// eine reine Linie ohne Spitze war nicht eindeutig lesbar (nicht erkennbar,
// welches Ende "vorne" ist). Die Spitze besteht aus zwei kurzen Strichen,
@@ -491,6 +535,7 @@ void render(TFT_eSPI& tft, int16_t top) {
bool selectionStillPresent = false;
Aircraft selected{};
uint8_t visibleCount = 0; // nach allen Filtern (Reichweite, Bodenfahrzeuge, Airline-Filter)
for (uint8_t i = 0; i < MAX_HIT_POINTS; i++) hitPoints[i].valid = false;
@@ -504,6 +549,8 @@ void render(TFT_eSPI& tft, int16_t top) {
if (AirlineFilter::isHidden(a.callsign)) continue;
visibleCount++;
RadarMath::PolarCoord polar{a.distanceKm, a.bearingDeg};
RadarMath::ScreenPoint pt = RadarMath::toScreen(polar, L.cx, L.cy, L.radius, rangeKm);
@@ -522,6 +569,7 @@ void render(TFT_eSPI& tft, int16_t top) {
tft.drawCircle(pt.x, pt.y, 9, TFT_WHITE);
selectionStillPresent = true;
selected = a;
drawBearingIndicator(tft, L, a.bearingDeg);
}
drawAircraftMarker(tft, pt.x, pt.y, a.headingDeg, color);
@@ -549,6 +597,12 @@ void render(TFT_eSPI& tft, int16_t top) {
strncpy(hitPoints[i].callsign, a.callsign, sizeof(hitPoints[i].callsign) - 1);
}
// "Leerer Himmel"-Timer: merkt sich, wann zuletzt mindestens ein
// Flugzeug sichtbar war (nach allen Filtern). Statisch, damit der Wert
// über mehrere render()-Aufrufe hinweg erhalten bleibt.
static uint32_t lastAircraftSeenMs = millis();
if (visibleCount > 0) lastAircraftSeenMs = millis();
if (selectedHex[0] && !selectionStillPresent) {
selectedHex[0] = 0;
}
@@ -563,7 +617,21 @@ void render(TFT_eSPI& tft, int16_t top) {
tft.drawFastHLine(0, infoTop, Config::SCREEN_WIDTH, TFT_DARKGREY);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(8, infoTop + 20);
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS));
if (visibleCount == 0) {
// Kein Flugzeug in Reichweite - statt des "Fuer Details
// antippen"-Hinweises (der hier ohnehin ins Leere liefe) zeigen
// wir an, wie lange der Himmel schon leer ist.
uint32_t emptySec = (millis() - lastAircraftSeenMs) / 1000;
char buf[24];
if (emptySec < 60) {
snprintf(buf, sizeof(buf), "%lus", (unsigned long)emptySec);
} else {
snprintf(buf, sizeof(buf), "%lum %02lus", (unsigned long)(emptySec / 60), (unsigned long)(emptySec % 60));
}
tft.print(String(I18n::t(StringId::RADAR_EMPTY_SKY_PREFIX)) + buf);
} else {
tft.print(I18n::t(StringId::RADAR_TAP_FOR_DETAILS));
}
char rangeLabel[8];
snprintf(rangeLabel, sizeof(rangeLabel), "%.0fkm", rangeKm);

View File

@@ -1,5 +1,6 @@
#include "splash_screen.h"
#include "menu_stars.h"
#include "i18n.h"
#include <math.h>
namespace SplashScreen {
@@ -131,6 +132,30 @@ void begin(TFT_eSPI& tft) {
tft.setTextSize(1);
}
void showGreeting(TFT_eSPI& tft) {
// Ohne per NTP synchronisierte Uhrzeit koennen wir die Tageszeit nicht
// bestimmen - dann lieber nichts anzeigen als zu raten (gleiches Muster
// wie bei der Nachtdimmung in main.cpp::isNightDimHours()).
time_t now = time(nullptr);
if (now <= 8 * 3600 * 2) return;
struct tm tmNow;
localtime_r(&now, &tmNow);
int h = tmNow.tm_hour;
StringId greetingId;
if (h < 12) greetingId = StringId::SPLASH_GREETING_MORNING;
else if (h < 18) greetingId = StringId::SPLASH_GREETING_DAY;
else greetingId = StringId::SPLASH_GREETING_EVENING;
int16_t cx = tft.width() / 2;
tft.setTextDatum(MC_DATUM);
tft.setTextColor(TFT_DARKGREEN, TFT_BLACK);
tft.setTextSize(1);
tft.drawString(I18n::t(greetingId), cx, 84);
tft.setTextDatum(TL_DATUM);
}
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color) {
if (slot >= MAX_STATUS_LINES) return;

View File

@@ -7,6 +7,13 @@ namespace SplashScreen {
void setStatusLine(TFT_eSPI& tft, uint8_t slot, const String& text, uint16_t color = TFT_WHITE);
// Zeigt eine tageszeit-abhaengige Begruessung (Morgen/Tag/Abend) unter dem
// "Flightradar"-Titel an. Braucht eine per NTP synchronisierte Uhrzeit -
// ist sie noch nicht verfuegbar, wird nichts gezeichnet (kein Raten).
// Am besten NACH der WLAN-Verbindung + Zeit-Synchronisierung aufrufen,
// NICHT direkt in begin() (die Uhrzeit steht zu dem Zeitpunkt noch nicht).
void showGreeting(TFT_eSPI& tft);
// Blockiert, bis seit begin() mindestens MIN_DISPLAY_MS vergangen sind.
// Direkt vor dem Verlassen des Splash-Screens aufrufen. Zeichnet waehrend
// des Wartens dieselbe Sterne-Animation wie die Menues.