From b2fcfe526e1d6b743895effa5d335ad9293fe0ca Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:42:18 -0600 Subject: [PATCH] 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) --- src/config/BoardConfig.h | 2 +- src/config/Config.h | 4 +- src/config/UserConfig.cpp | 6 +- src/config/UserConfig.h | 3 +- src/hal/GPSManager.cpp | 33 +++--- src/hal/GPSManager.h | 8 +- src/hal/NMEAParser.h | 22 ++-- src/main.cpp | 96 ++++++++++++----- src/ui/LvStatusBar.cpp | 11 -- src/ui/LvStatusBar.h | 1 - src/ui/screens/LvNameInputScreen.cpp | 2 + src/ui/screens/LvNameInputScreen.h | 3 + src/ui/screens/LvSettingsScreen.cpp | 21 ++-- src/ui/screens/LvTimezoneScreen.cpp | 152 +++++++++++++++++++++++++++ src/ui/screens/LvTimezoneScreen.h | 63 +++++++++++ 15 files changed, 340 insertions(+), 87 deletions(-) create mode 100644 src/ui/screens/LvTimezoneScreen.cpp create mode 100644 src/ui/screens/LvTimezoneScreen.h diff --git a/src/config/BoardConfig.h b/src/config/BoardConfig.h index 69db942..e822584 100644 --- a/src/config/BoardConfig.h +++ b/src/config/BoardConfig.h @@ -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 diff --git a/src/config/Config.h b/src/config/Config.h index babeadf..286df73 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -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 diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp index c0479b5..75e7523 100644 --- a/src/config/UserConfig.cpp +++ b/src/config/UserConfig.cpp @@ -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; diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h index ed39ec8..80daafc 100644 --- a/src/config/UserConfig.h +++ b/src/config/UserConfig.h @@ -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 diff --git a/src/hal/GPSManager.cpp b/src/hal/GPSManager.cpp index d10275e..6f3107f 100644 --- a/src/hal/GPSManager.cpp +++ b/src/hal/GPSManager.cpp @@ -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" 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 diff --git a/src/hal/GPSManager.h b/src/hal/GPSManager.h index 0ee8b53..d73c524 100644 --- a/src/hal/GPSManager.h +++ b/src/hal/GPSManager.h @@ -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 diff --git a/src/hal/NMEAParser.h b/src/hal/NMEAParser.h index 364c40e..8db8c2a 100644 --- a/src/hal/NMEAParser.h +++ b/src/hal/NMEAParser.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index 6a45288..457b8bb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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) diff --git a/src/ui/LvStatusBar.cpp b/src/ui/LvStatusBar.cpp index 48e902b..5654bc8 100644 --- a/src/ui/LvStatusBar.cpp +++ b/src/ui/LvStatusBar.cpp @@ -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) { diff --git a/src/ui/LvStatusBar.h b/src/ui/LvStatusBar.h index fe5ca09..54ca148 100644 --- a/src/ui/LvStatusBar.h +++ b/src/ui/LvStatusBar.h @@ -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; diff --git a/src/ui/screens/LvNameInputScreen.cpp b/src/ui/screens/LvNameInputScreen.cpp index e2b1136..4c104b6 100644 --- a/src/ui/screens/LvNameInputScreen.cpp +++ b/src/ui/screens/LvNameInputScreen.cpp @@ -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 : "")); diff --git a/src/ui/screens/LvNameInputScreen.h b/src/ui/screens/LvNameInputScreen.h index 9ab37d6..7019dfd 100644 --- a/src/ui/screens/LvNameInputScreen.h +++ b/src/ui/screens/LvNameInputScreen.h @@ -10,10 +10,13 @@ public: const char* title() const override { return "Setup"; } void setDoneCallback(std::function cb) { _doneCb = cb; } + void onEnter() override { _enterTime = millis(); } static constexpr int MAX_NAME_LEN = 16; private: lv_obj_t* _textarea = nullptr; std::function _doneCb; + unsigned long _enterTime = 0; + static constexpr unsigned long ENTER_GUARD_MS = 600; // Ignore Enter for 600ms after screen appears }; diff --git a/src/ui/screens/LvSettingsScreen.cpp b/src/ui/screens/LvSettingsScreen.cpp index 19272d7..d596e56 100644 --- a/src/ui/screens/LvSettingsScreen.cpp +++ b/src/ui/screens/LvSettingsScreen.cpp @@ -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); diff --git a/src/ui/screens/LvTimezoneScreen.cpp b/src/ui/screens/LvTimezoneScreen.cpp new file mode 100644 index 0000000..ab221b8 --- /dev/null +++ b/src/ui/screens/LvTimezoneScreen.cpp @@ -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 +} diff --git a/src/ui/screens/LvTimezoneScreen.h b/src/ui/screens/LvTimezoneScreen.h new file mode 100644 index 0000000..1ec0670 --- /dev/null +++ b/src/ui/screens/LvTimezoneScreen.h @@ -0,0 +1,63 @@ +#pragma once + +#include "ui/UIManager.h" +#include + +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 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 _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; +};