v1.7.1: GPS time sync, timezone picker, status bar clock

- GPS time sync via UBlox MIA-M10Q (38400 baud auto-detect)
- Custom zero-dependency NMEA parser with XOR checksum validation
- Only trust GPS time when satellites > 0 (no stale RTC cache)
- DST-aware timezone picker at first boot (21 cities, POSIX TZ strings)
- Timezone also configurable in Settings > GPS/Time
- GPS Time on by default, GPS Location off by default (opt-in)
- Status bar: clock (top-left), Ratspeak.org (center), battery (right)
- 12h time default (no AM/PM), configurable 24h in settings
- NVS persistence of GPS time for approximate timestamps across reboots
- Fix factory reset Enter key bleed-through (600ms input guard)
- Fix hardcoded UTC-5 NTP timezone (now uses DST-aware POSIX TZ)
This commit is contained in:
DeFiDude
2026-03-25 02:42:18 -06:00
parent 311c5ad3df
commit b2fcfe526e
15 changed files with 340 additions and 87 deletions

View File

@@ -68,7 +68,7 @@
// --- GPS (UBlox MIA-M10Q UART) ---
#define GPS_TX 43 // ESP TX -> GPS RX
#define GPS_RX 44 // GPS TX -> ESP RX
#define GPS_BAUD 115200
#define GPS_BAUD 38400 // UBlox MIA-M10Q factory default
// --- Battery ADC ---
#define BAT_ADC_PIN 4

View File

@@ -6,8 +6,8 @@
#define RATDECK_VERSION_MAJOR 1
#define RATDECK_VERSION_MINOR 7
#define RATDECK_VERSION_PATCH 0
#define RATDECK_VERSION_STRING "1.7.0"
#define RATDECK_VERSION_PATCH 1
#define RATDECK_VERSION_STRING "1.7.1"
// --- Feature Flags ---
#define HAS_DISPLAY true

View File

@@ -57,7 +57,8 @@ bool UserConfig::parseJson(const String& json) {
_settings.gpsTimeEnabled = doc["gps_time"] | true;
_settings.gpsLocationEnabled = doc["gps_location"] | false;
_settings.utcOffset = doc["utc_offset"] | (int)-5;
_settings.timezoneIdx = doc["tz_idx"] | 6;
_settings.timezoneSet = doc["tz_set"] | false;
_settings.use24HourTime = doc["time_24h"] | false;
_settings.audioEnabled = doc["audio_on"] | true;
@@ -105,7 +106,8 @@ String UserConfig::serializeToJson() const {
doc["gps_time"] = _settings.gpsTimeEnabled;
doc["gps_location"] = _settings.gpsLocationEnabled;
doc["utc_offset"] = _settings.utcOffset;
doc["tz_idx"] = _settings.timezoneIdx;
doc["tz_set"] = _settings.timezoneSet;
doc["time_24h"] = _settings.use24HourTime;
doc["audio_on"] = _settings.audioEnabled;

View File

@@ -53,7 +53,8 @@ struct UserSettings {
// GPS & Time
bool gpsTimeEnabled = true; // GPS time sync (default ON)
bool gpsLocationEnabled = false; // GPS position tracking (default OFF, user must opt in)
int8_t utcOffset = -5; // Hours from UTC (default EST)
uint8_t timezoneIdx = 6; // Index into TIMEZONE_TABLE (default: New York EST/EDT)
bool timezoneSet = false; // false = show timezone picker at boot
bool use24HourTime = false; // false = 12h (no AM/PM), true = 24h
// Audio

View File

@@ -65,9 +65,15 @@ void GPSManager::loop() {
NMEAData& d = _parser.data();
if (d.timeUpdated) {
d.timeUpdated = false;
_lastFixMs = millis();
if (d.timeValid && (millis() - _lastTimeSyncMs >= TIME_SYNC_INTERVAL_MS || _timeSyncCount == 0)) {
// Only trust GPS time when we have actual satellite lock (sats > 0).
// With sats=0 the module returns cached RTC time which drifts.
bool hasSatFix = (d.satellites > 0);
if (hasSatFix) _lastFixMs = millis();
if (d.timeValid && hasSatFix && (millis() - _lastTimeSyncMs >= TIME_SYNC_INTERVAL_MS || _timeSyncCount == 0)) {
syncSystemTime();
// Persist time to NVS so reboots without WiFi/GPS have approximate time
persistToNVS();
_lastPersistMs = millis();
}
}
@@ -76,12 +82,6 @@ void GPSManager::loop() {
d.locationUpdated = false;
_locationValid = d.locationValid;
_lastFixMs = millis();
// Periodic NVS persist
if (_locationValid && (millis() - _lastPersistMs >= PERSIST_INTERVAL_MS)) {
persistToNVS();
_lastPersistMs = millis();
}
}
// Stale fix detection
@@ -150,12 +150,8 @@ void GPSManager::syncSystemTime() {
_lastTimeSyncMs = millis();
_timeSyncCount++;
// Set TZ from utcOffset so localtime() works correctly
// POSIX TZ: "UTC<offset>" where offset sign is inverted
// e.g., UTC-5 (EST) → "UTC5" in POSIX notation
char tz[16];
snprintf(tz, sizeof(tz), "UTC%d", -_utcOffset);
setenv("TZ", tz, 1);
// Set TZ so localtime() returns correct local time (with DST if applicable)
setenv("TZ", _posixTZ, 1);
tzset();
Serial.printf("[GPS] System time synced: %04d-%02d-%02d %02d:%02d:%02d UTC (sync #%lu)\n",
@@ -177,9 +173,7 @@ void GPSManager::restoreTimeFromNVS() {
settimeofday(&tv, nullptr);
// Set TZ
char tz[16];
snprintf(tz, sizeof(tz), "UTC%d", -_utcOffset);
setenv("TZ", tz, 1);
setenv("TZ", _posixTZ, 1);
tzset();
time_t now = time(nullptr);
@@ -215,4 +209,9 @@ void GPSManager::persistToNVS() {
prefs.end();
}
void GPSManager::setPosixTZ(const char* tz) {
strncpy(_posixTZ, tz, sizeof(_posixTZ) - 1);
_posixTZ[sizeof(_posixTZ) - 1] = '\0';
}
#endif // HAS_GPS

View File

@@ -36,7 +36,7 @@ public:
bool isRunning() const { return _running; }
// Configuration (set from outside before or after begin())
void setUTCOffset(int8_t offset) { _utcOffset = offset; }
void setPosixTZ(const char* tz);
void setLocationEnabled(bool enable) { _parser.setParseLocation(enable); }
private:
@@ -54,16 +54,16 @@ private:
unsigned long _lastFixMs = 0;
unsigned long _lastPersistMs = 0;
uint32_t _timeSyncCount = 0;
int8_t _utcOffset = -5;
char _posixTZ[48] = "EST5EDT,M3.2.0,M11.1.0"; // POSIX TZ string
uint32_t _detectedBaud = 0;
// Baud auto-detect state
bool _baudDetected = false;
int _baudAttemptIdx = 0;
unsigned long _baudAttemptStart = 0;
static constexpr uint32_t BAUD_RATES[] = {115200, 38400, 9600};
static constexpr uint32_t BAUD_RATES[] = {38400, 115200, 9600};
static constexpr int BAUD_RATE_COUNT = 3;
static constexpr unsigned long BAUD_DETECT_TIMEOUT_MS = 10000;
static constexpr unsigned long BAUD_DETECT_TIMEOUT_MS = 3000;
static constexpr unsigned long TIME_SYNC_INTERVAL_MS = 60000; // Re-sync every 60s
static constexpr unsigned long PERSIST_INTERVAL_MS = 300000; // Persist to NVS every 5 min

View File

@@ -217,22 +217,18 @@ private:
// $xxGGA — Global Positioning System Fix Data
// fields: type,time,lat,N/S,lon,E/W,quality,sats,hdop,alt,M,geoid,M,age,station
bool parseGGA(char* fields[], int n) {
if (!_parseLocation) return true; // Skip entirely when location disabled
// Fix quality
// Always parse fix quality and satellites (needed for time validation)
_data.fixQuality = atoi(fields[6]);
// Satellites
_data.satellites = atoi(fields[7]);
// HDOP
if (strlen(fields[8]) > 0) {
_data.hdop = atof(fields[8]);
}
// Altitude above MSL
if (n >= 10 && strlen(fields[9]) > 0) {
_data.altitude = atof(fields[9]);
// Only parse position fields if location tracking is enabled
if (_parseLocation) {
if (strlen(fields[8]) > 0) {
_data.hdop = atof(fields[8]);
}
if (n >= 10 && strlen(fields[9]) > 0) {
_data.altitude = atof(fields[9]);
}
}
return true;

View File

@@ -34,6 +34,7 @@
#include "ui/screens/LvHelpOverlay.h"
// Map screen removed
#include "ui/screens/LvNameInputScreen.h"
#include "ui/screens/LvTimezoneScreen.h"
#include "ui/screens/LvDataCleanScreen.h"
#include "storage/FlashStore.h"
#include "storage/SDStore.h"
@@ -107,6 +108,7 @@ LvSettingsScreen lvSettingsScreen;
LvHelpOverlay lvHelpOverlay;
// LvMapScreen removed
LvNameInputScreen lvNameInputScreen;
LvTimezoneScreen lvTimezoneScreen;
LvDataCleanScreen lvDataCleanScreen;
// Tab-screen mapping (4 tabs) — LVGL versions
@@ -131,6 +133,16 @@ constexpr unsigned long TCP_GLOBAL_BUDGET_MS = 35; // Max cumulative TCP ti
bool wifiDeferredAnnounce = false;
unsigned long wifiConnectedAt = 0;
// =============================================================================
// Timezone helper — returns POSIX TZ string for current config
// =============================================================================
static const char* currentPosixTZ() {
uint8_t idx = userConfig.settings().timezoneIdx;
if (idx < TIMEZONE_COUNT) return TIMEZONE_TABLE[idx].posixTZ;
return "EST5EDT,M3.2.0,M11.1.0"; // Fallback
}
// =============================================================================
// Announce with display name (MessagePack-encoded app_data)
// =============================================================================
@@ -646,7 +658,7 @@ void setup() {
#if HAS_GPS
if (userConfig.settings().gpsTimeEnabled) {
lvBootScreen.setProgress(0.93f, "Starting GPS...");
gps.setUTCOffset(userConfig.settings().utcOffset);
gps.setPosixTZ(currentPosixTZ());
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
gps.begin();
Serial.println("[BOOT] GPS UART started (MIA-M10Q)");
@@ -754,7 +766,7 @@ void setup() {
#if HAS_GPS
lvSettingsScreen.setGPSChangeCallback([](bool timeEnabled) {
if (timeEnabled) {
gps.setUTCOffset(userConfig.settings().utcOffset);
gps.setPosixTZ(currentPosixTZ());
gps.setLocationEnabled(userConfig.settings().gpsLocationEnabled);
gps.begin();
Serial.println("[GPS] Time enabled via settings");
@@ -797,8 +809,49 @@ void setup() {
}
});
// --- Boot flow helpers ---
// Transition to home screen (shared by name input, timezone, and normal boot)
auto goHome = []() {
ui.setBootMode(false);
ui.setLvScreen(&lvHomeScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
announceWithName();
lastAutoAnnounce = millis();
Serial.println("[BOOT] Initial announce sent");
};
// Show timezone screen, then go home
auto showTimezone = [goHome]() {
if (!userConfig.settings().timezoneSet) {
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
ui.setLvScreen(&lvTimezoneScreen);
Serial.println("[BOOT] Showing timezone selection");
} else {
goHome();
}
};
// Timezone screen done callback
lvTimezoneScreen.setDoneCallback([goHome](int tzIdx) {
userConfig.settings().timezoneIdx = (uint8_t)tzIdx;
userConfig.settings().timezoneSet = true;
userConfig.save(sdStore, flash);
Serial.printf("[BOOT] Timezone set: %s (%s)\n",
TIMEZONE_TABLE[tzIdx].label, TIMEZONE_TABLE[tzIdx].posixTZ);
// Apply timezone immediately
const char* tz = TIMEZONE_TABLE[tzIdx].posixTZ;
setenv("TZ", tz, 1);
tzset();
#if HAS_GPS
if (userConfig.settings().gpsTimeEnabled) {
gps.setPosixTZ(tz);
}
#endif
goHome();
});
// Name input screen (first boot only — when no display name is set)
lvNameInputScreen.setDoneCallback([](const String& name) {
lvNameInputScreen.setDoneCallback([showTimezone](const String& name) {
String finalName = name;
if (finalName.isEmpty()) {
// Auto-generate: Ratspeak.org-xxx (first 3 chars of LXMF dest hash)
@@ -813,31 +866,22 @@ void setup() {
}
Serial.printf("[BOOT] Display name set: '%s'\n", finalName.c_str());
// Transition to home screen
ui.setBootMode(false);
ui.setLvScreen(&lvHomeScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
// Initial announce with name
announceWithName();
lastAutoAnnounce = millis();
Serial.println("[BOOT] Initial announce sent");
// Next step: timezone selection (or home if already set)
showTimezone();
});
if (userConfig.settings().displayName.isEmpty()) {
// First boot — go to name input (SD data is handled gracefully by dual-backend)
// First boot — go to name input
ui.setLvScreen(&lvNameInputScreen);
Serial.println("[BOOT] Showing name input screen");
} else if (!userConfig.settings().timezoneSet) {
// Name set but timezone not — show timezone picker
lvTimezoneScreen.setSelectedIndex(userConfig.settings().timezoneIdx);
ui.setLvScreen(&lvTimezoneScreen);
Serial.println("[BOOT] Showing timezone selection (name already set)");
} else {
// Name already set — go straight to home
ui.setBootMode(false);
ui.setLvScreen(&lvHomeScreen);
ui.lvTabBar().setActiveTab(LvTabBar::TAB_HOME);
// Initial announce with name
announceWithName();
lastAutoAnnounce = millis();
Serial.println("[BOOT] Initial announce sent");
// Everything configured — go straight to home
goHome();
}
// Clear boot loop counter — we survived!
@@ -956,13 +1000,11 @@ void loop() {
ui.lvStatusBar().setWiFiActive(true);
Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str());
// NTP time sync (configurable UTC offset)
// NTP time sync (DST-aware POSIX TZ string)
{
int8_t off = userConfig.settings().utcOffset;
char tz[16];
snprintf(tz, sizeof(tz), "UTC%d", -off);
const char* tz = currentPosixTZ();
configTzTime(tz, "pool.ntp.org", "time.nist.gov");
Serial.printf("[NTP] Time sync started (UTC%+d, TZ=%s)\n", off, tz);
Serial.printf("[NTP] Time sync started (TZ=%s)\n", tz);
}
// Recreate TCP clients on every WiFi connect (old clients may have stale sockets)

View File

@@ -32,13 +32,6 @@ void LvStatusBar::create(lv_obj_t* parent) {
lv_label_set_text(_lblBrand, "Ratspeak.org");
lv_obj_align(_lblBrand, LV_ALIGN_CENTER, 0, 0);
// Right: GPS indicator (left of battery, hidden until fix)
_lblGPS = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblGPS, font, 0);
lv_obj_set_style_text_color(_lblGPS, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(_lblGPS, "");
lv_obj_align(_lblGPS, LV_ALIGN_RIGHT_MID, -42, 0);
// Right: Battery %
_lblBatt = lv_label_create(_bar);
lv_obj_set_style_text_font(_lblBatt, font, 0);
@@ -104,11 +97,7 @@ void LvStatusBar::updateTime() {
}
void LvStatusBar::setGPSFix(bool hasFix) {
if (_gpsFix == hasFix) return;
_gpsFix = hasFix;
if (_lblGPS) {
lv_label_set_text(_lblGPS, hasFix ? "GPS" : "");
}
}
void LvStatusBar::setBatteryPercent(int pct) {

View File

@@ -31,7 +31,6 @@ private:
lv_obj_t* _bar = nullptr;
lv_obj_t* _lblTime = nullptr; // Top-left: current time
lv_obj_t* _lblBrand = nullptr; // Center: "Ratspeak.org"
lv_obj_t* _lblGPS = nullptr; // Right: GPS indicator
lv_obj_t* _lblBatt = nullptr; // Right: battery %
lv_obj_t* _toast = nullptr;
lv_obj_t* _lblToast = nullptr;

View File

@@ -60,6 +60,8 @@ bool LvNameInputScreen::handleKey(const KeyEvent& event) {
if (!_textarea) return false;
if (event.enter || event.character == '\n' || event.character == '\r') {
// Guard: ignore Enter for first 600ms to prevent stale keypress from factory reset
if (millis() - _enterTime < ENTER_GUARD_MS) return true;
const char* text = lv_textarea_get_text(_textarea);
if (_doneCb) {
_doneCb(String(text && strlen(text) > 0 ? text : ""));

View File

@@ -10,10 +10,13 @@ public:
const char* title() const override { return "Setup"; }
void setDoneCallback(std::function<void(const String&)> cb) { _doneCb = cb; }
void onEnter() override { _enterTime = millis(); }
static constexpr int MAX_NAME_LEN = 16;
private:
lv_obj_t* _textarea = nullptr;
std::function<void(const String&)> _doneCb;
unsigned long _enterTime = 0;
static constexpr unsigned long ENTER_GUARD_MS = 600; // Ignore Enter for 600ms after screen appears
};

View File

@@ -3,6 +3,7 @@
#include "ui/LvTheme.h"
#include "config/Config.h"
#include "config/UserConfig.h"
#include "ui/screens/LvTimezoneScreen.h" // For TIMEZONE_TABLE
#include "storage/FlashStore.h"
#include "storage/SDStore.h"
#include "radio/SX1262.h"
@@ -382,10 +383,14 @@ void LvSettingsScreen::buildItems() {
[](int v) { return v ? String("ON") : String("OFF"); }});
idx++;
#endif
_items.push_back({"UTC Offset", SettingType::INTEGER,
[&s]() { return (int)s.utcOffset; }, [&s](int v) { s.utcOffset = (int8_t)v; },
[](int v) { char buf[8]; snprintf(buf, sizeof(buf), "UTC%+d", v); return String(buf); },
-12, 14, 1});
_items.push_back({"Timezone", SettingType::INTEGER,
[&s]() { return (int)s.timezoneIdx; },
[&s](int v) { s.timezoneIdx = (uint8_t)v; s.timezoneSet = true; },
[](int v) {
if (v >= 0 && v < TIMEZONE_COUNT) return String(TIMEZONE_TABLE[v].label);
return String("Unknown");
},
0, TIMEZONE_COUNT - 1, 1});
idx++;
_items.push_back({"24h Time", SettingType::TOGGLE,
[&s]() { return s.use24HourTime ? 1 : 0; },
@@ -394,9 +399,9 @@ void LvSettingsScreen::buildItems() {
idx++;
_categories.push_back({"GPS/Time", gpsStart, idx - gpsStart,
[&s]() {
char buf[16];
snprintf(buf, sizeof(buf), "UTC%+d", s.utcOffset);
return String(buf);
if (s.timezoneIdx < TIMEZONE_COUNT)
return String(TIMEZONE_TABLE[s.timezoneIdx].label);
return String("Not set");
}});
// Audio
@@ -540,7 +545,7 @@ void LvSettingsScreen::buildItems() {
if (_sd && _sd->isReady()) _sd->wipeRatputer();
if (_flash) _flash->format();
nvs_flash_erase();
delay(500);
delay(1500); // Long enough for key state to clear before reboot
ESP.restart();
};
_items.push_back(factoryReset);

View File

@@ -0,0 +1,152 @@
#include "LvTimezoneScreen.h"
#include "ui/Theme.h"
#include "ui/LvTheme.h"
#include "config/Config.h"
void LvTimezoneScreen::createUI(lv_obj_t* parent) {
_screen = parent;
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
const lv_font_t* font12 = &lv_font_montserrat_12;
const lv_font_t* font14 = &lv_font_montserrat_14;
// Title
lv_obj_t* title = lv_label_create(parent);
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0);
lv_label_set_text(title, "SELECT TIMEZONE");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8);
// Subtitle
lv_obj_t* sub = lv_label_create(parent);
lv_obj_set_style_text_font(sub, font12, 0);
lv_obj_set_style_text_color(sub, lv_color_hex(Theme::SECONDARY), 0);
lv_label_set_text(sub, "Choose your nearest city:");
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 30);
// Scrollable list container
int listY = 50;
int listH = VISIBLE_ROWS * ROW_H;
_scrollContainer = lv_obj_create(parent);
lv_obj_set_size(_scrollContainer, Theme::SCREEN_W - 20, listH);
lv_obj_set_pos(_scrollContainer, 10, listY);
lv_obj_set_style_bg_color(_scrollContainer, lv_color_hex(Theme::BG), 0);
lv_obj_set_style_bg_opa(_scrollContainer, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(_scrollContainer, 1, 0);
lv_obj_set_style_border_color(_scrollContainer, lv_color_hex(Theme::BORDER), 0);
lv_obj_set_style_pad_all(_scrollContainer, 0, 0);
lv_obj_set_style_radius(_scrollContainer, 4, 0);
lv_obj_set_layout(_scrollContainer, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(_scrollContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_snap_y(_scrollContainer, LV_SCROLL_SNAP_CENTER);
lv_obj_set_scrollbar_mode(_scrollContainer, LV_SCROLLBAR_MODE_OFF);
// Create rows
for (int i = 0; i < TIMEZONE_COUNT; i++) {
lv_obj_t* row = lv_obj_create(_scrollContainer);
lv_obj_set_size(row, Theme::SCREEN_W - 24, ROW_H);
lv_obj_set_style_pad_left(row, 8, 0);
lv_obj_set_style_pad_right(row, 8, 0);
lv_obj_set_style_pad_top(row, 0, 0);
lv_obj_set_style_pad_bottom(row, 0, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_set_style_radius(row, 2, 0);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
bool selected = (i == _selectedIdx);
lv_obj_set_style_bg_color(row, lv_color_hex(selected ? Theme::SELECTION_BG : Theme::BG), 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
// City name (left)
lv_obj_t* lblCity = lv_label_create(row);
lv_obj_set_style_text_font(lblCity, font14, 0);
lv_obj_set_style_text_color(lblCity, lv_color_hex(selected ? Theme::BG : Theme::PRIMARY), 0);
lv_label_set_text(lblCity, TIMEZONE_TABLE[i].label);
lv_obj_align(lblCity, LV_ALIGN_LEFT_MID, 0, 0);
// UTC offset (right)
lv_obj_t* lblOffset = lv_label_create(row);
lv_obj_set_style_text_font(lblOffset, font12, 0);
lv_obj_set_style_text_color(lblOffset, lv_color_hex(selected ? Theme::BG : Theme::ACCENT), 0);
char buf[12];
int8_t off = TIMEZONE_TABLE[i].baseOffset;
snprintf(buf, sizeof(buf), "UTC%+d", off);
lv_label_set_text(lblOffset, buf);
lv_obj_align(lblOffset, LV_ALIGN_RIGHT_MID, 0, 0);
}
// Scroll initial selection into view
if (_selectedIdx >= 0 && _selectedIdx < TIMEZONE_COUNT) {
lv_obj_t* selRow = lv_obj_get_child(_scrollContainer, _selectedIdx);
if (selRow) lv_obj_scroll_to_view(selRow, LV_ANIM_OFF);
}
// Hint at bottom
lv_obj_t* hint = lv_label_create(parent);
lv_obj_set_style_text_font(hint, font12, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(Theme::ACCENT), 0);
lv_label_set_text(hint, "[Enter] Select");
lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -10);
}
void LvTimezoneScreen::updateSelection(int oldIdx, int newIdx) {
if (!_scrollContainer) return;
uint32_t count = lv_obj_get_child_cnt(_scrollContainer);
const lv_font_t* font14 = &lv_font_montserrat_14;
const lv_font_t* font12 = &lv_font_montserrat_12;
// Deselect old
if (oldIdx >= 0 && oldIdx < (int)count) {
lv_obj_t* row = lv_obj_get_child(_scrollContainer, oldIdx);
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::BG), 0);
// Update text colors
lv_obj_t* lblCity = lv_obj_get_child(row, 0);
lv_obj_t* lblOff = lv_obj_get_child(row, 1);
if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::PRIMARY), 0);
if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::ACCENT), 0);
}
// Select new
if (newIdx >= 0 && newIdx < (int)count) {
lv_obj_t* row = lv_obj_get_child(_scrollContainer, newIdx);
lv_obj_set_style_bg_color(row, lv_color_hex(Theme::SELECTION_BG), 0);
lv_obj_t* lblCity = lv_obj_get_child(row, 0);
lv_obj_t* lblOff = lv_obj_get_child(row, 1);
if (lblCity) lv_obj_set_style_text_color(lblCity, lv_color_hex(Theme::BG), 0);
if (lblOff) lv_obj_set_style_text_color(lblOff, lv_color_hex(Theme::BG), 0);
// Scroll to make visible
lv_obj_scroll_to_view(row, LV_ANIM_ON);
}
}
bool LvTimezoneScreen::handleKey(const KeyEvent& event) {
if (event.enter || event.character == '\n' || event.character == '\r') {
// Guard: ignore Enter for first 600ms to prevent stale keypress bleed
if (millis() - _enterTime < ENTER_GUARD_MS) return true;
if (_doneCb) _doneCb(_selectedIdx);
return true;
}
if (event.up) {
if (_selectedIdx > 0) {
int old = _selectedIdx;
_selectedIdx--;
updateSelection(old, _selectedIdx);
}
return true;
}
if (event.down) {
if (_selectedIdx < TIMEZONE_COUNT - 1) {
int old = _selectedIdx;
_selectedIdx++;
updateSelection(old, _selectedIdx);
}
return true;
}
return true; // Consume all keys
}

View File

@@ -0,0 +1,63 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
struct TimezoneEntry {
const char* label; // Display name (e.g., "New York (EST/EDT)")
const char* posixTZ; // POSIX TZ string for setenv("TZ", ...)
int8_t baseOffset; // Base UTC offset for display (e.g., -5 for EST)
};
// Major world timezones with DST rules where applicable
static const TimezoneEntry TIMEZONE_TABLE[] = {
{"Honolulu", "HST10", -10},
{"Anchorage", "AKST9AKDT,M3.2.0,M11.1.0", -9},
{"Los Angeles", "PST8PDT,M3.2.0,M11.1.0", -8},
{"Denver", "MST7MDT,M3.2.0,M11.1.0", -7},
{"Phoenix", "MST7", -7},
{"Chicago", "CST6CDT,M3.2.0,M11.1.0", -6},
{"New York", "EST5EDT,M3.2.0,M11.1.0", -5},
{"San Juan", "AST4", -4},
{"Sao Paulo", "<-03>3", -3},
{"London", "GMT0BST,M3.5.0/1,M10.5.0", 0},
{"Paris", "CET-1CEST,M3.5.0,M10.5.0/3", 1},
{"Cairo", "EET-2", 2},
{"Moscow", "MSK-3", 3},
{"Dubai", "<+04>-4", 4},
{"Karachi", "PKT-5", 5},
{"Kolkata", "IST-5:30", 5},
{"Bangkok", "<+07>-7", 7},
{"Singapore", "<+08>-8", 8},
{"Tokyo", "JST-9", 9},
{"Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3", 10},
{"Auckland", "NZST-12NZDT,M9.5.0,M4.1.0/3", 12},
};
static constexpr int TIMEZONE_COUNT = sizeof(TIMEZONE_TABLE) / sizeof(TIMEZONE_TABLE[0]);
class LvTimezoneScreen : public LvScreen {
public:
void createUI(lv_obj_t* parent) override;
bool handleKey(const KeyEvent& event) override;
void onEnter() override { _enterTime = millis(); }
const char* title() const override { return "Timezone"; }
void setDoneCallback(std::function<void(int tzIndex)> cb) { _doneCb = cb; }
// Pre-select an index (e.g., from saved config)
void setSelectedIndex(int idx) { _selectedIdx = idx; }
private:
void updateSelection(int oldIdx, int newIdx);
std::function<void(int)> _doneCb;
int _selectedIdx = 6; // Default: New York (EST/EDT)
unsigned long _enterTime = 0;
static constexpr unsigned long ENTER_GUARD_MS = 600;
// LVGL widgets
lv_obj_t* _scrollContainer = nullptr;
static constexpr int VISIBLE_ROWS = 5;
static constexpr int ROW_H = 28;
};