From 9ce4ddb753b3e337c600f9acef2a1bdd418214fa Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 19:24:26 -0400 Subject: [PATCH 1/4] refactor(#265): E4 band 4, unit 1 -- lvgl_port leaves src.ino src/lvgl_port.{h,cpp}: the five LVGL callbacks (tick, flush, log, touch read, encoder read), initFCTheme and its seven named styles (private: only the theme touches them), the display/indev/group objects and draw-buffer pointers (published: initLVGL, which stays in src.ino as the composition root, wires them), the touch statistics (published: the diagnostics page and loop read them). lvglTickCb loses static because initLVGL registers it -- the harness gained --publish-functions for exactly that. The button pins join fc_config.h (the encoder callback reads them). Verified by scripts/verify_extraction.py, PASS: 592 -> 592 symbols, 27 relocated, 13 linkage changes all declared, 5 size deltas net +2 all layout, 5 moved functions checked by reference set, unexplained none. Flash 1,856,690 -> 1,856,670 (-20), RAM unchanged. src.ino 4,387 -> 4,209. --- scripts/extract_unit.py | 13 ++- src/fc_config.h | 5 ++ src/lvgl_port.cpp | 162 ++++++++++++++++++++++++++++++++++++ src/lvgl_port.h | 26 ++++++ src/src.ino | 180 +--------------------------------------- 5 files changed, 205 insertions(+), 181 deletions(-) create mode 100644 src/lvgl_port.cpp create mode 100644 src/lvgl_port.h diff --git a/scripts/extract_unit.py b/scripts/extract_unit.py index 4ffd13f..5354459 100644 --- a/scripts/extract_unit.py +++ b/scripts/extract_unit.py @@ -251,6 +251,8 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--unit", required=True) ap.add_argument("--functions", default="") + ap.add_argument("--publish-functions", default="", + help="moved functions that lose `static` and gain a prototype: a caller stays behind") ap.add_argument("--move-vars", default="", help="variables moved into the .cpp, kept private") ap.add_argument("--publish-vars", default="", help="variables moved into the .cpp and declared extern in the header") ap.add_argument("--move-defines", default="") @@ -273,6 +275,8 @@ def main() -> int: split = lambda s: [n for n in s.split(",") if n] names, mvars, pvars = split(a.functions), split(a.move_vars), split(a.publish_vars) + pfuncs = split(a.publish_functions) + names += [n for n in pfuncs if n not in names] defines, decls = split(a.move_defines), split(a.move_decls) # ---- locate everything first; only then mutate -------------------------- @@ -281,7 +285,7 @@ def main() -> int: d = find_definition(lines, n) b = signature_end(lines, d) e = block_end(lines, b) - moves.append((comment_start(lines, d), d, b, e, n, is_static(lines, d))) + moves.append((comment_start(lines, d), d, b, e, n, is_static(lines, d) and n not in pfuncs)) moves.sort() for x, y in zip(moves, moves[1:]): if y[0] <= x[3]: @@ -374,7 +378,10 @@ def inc_line(inc: str) -> str: existing = {n: t for _, (n, t) in protos.items()} for c, d, b, e, n, st in moves: if not st: - h.append(existing.get(n, prototype(lines, d, b)) + nl) + p = existing.get(n, prototype(lines, d, b)) + if n in pfuncs: + p = re.sub(r"^static\s+", "", p) + h.append(p + nl) cpp = [] if not a.header_only: @@ -400,6 +407,8 @@ def inc_line(inc: str) -> str: cpp.append(nl) for i in range(c, e + 1): l = lines[i] + if i == d and n in pfuncs: + l = re.sub(r"^static\s+", "", l) # published: a caller stays behind if d <= i <= b and not st: # a default argument may appear once: the header's prototype # carries it, so the definition must not (parseGPXFromString) diff --git a/src/fc_config.h b/src/fc_config.h index 49f0783..363504f 100644 --- a/src/fc_config.h +++ b/src/fc_config.h @@ -8,3 +8,8 @@ #define FRAM_CS 15 // A3 -> FRAM CS #define TFT_BL 8 // A5 -> LED pin on display module (PWM-dimmable backlight) #define CTP_INT 14 // A4 -> Touch interrupt (active-low, CHANGE: fires on touch + release) + +// Button pins (directly wired, active LOW) +#define BUTTON_A 9 +#define BUTTON_B 6 +#define BUTTON_C 5 diff --git a/src/lvgl_port.cpp b/src/lvgl_port.cpp new file mode 100644 index 0000000..c3c23d9 --- /dev/null +++ b/src/lvgl_port.cpp @@ -0,0 +1,162 @@ +// lvgl_port.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "lvgl_port.h" +#include "fc_config.h" +#include "display.h" +#include "touch.h" +#include "ui_state.h" +#include "fc_theme.h" +#include "logging.h" + +lv_display_t* lvglDisplay = NULL; +uint8_t* lvglBuf1 = NULL; +uint8_t* lvglBuf2 = NULL; +bool lvglAvailable = false; +lv_indev_t* lvglTouchIndev = NULL; // FT6336U → LV_INDEV_TYPE_POINTER +lv_indev_t* lvglEncoderIndev = NULL; // Buttons → LV_INDEV_TYPE_ENCODER +lv_group_t* lvglGroup = NULL; // Focus group for encoder navigation +static lv_style_t fcStyleHeader; // Cyan, XL (24) — screen titles +static lv_style_t fcStyleValue; // Green, LG (20) — sensor values +static lv_style_t fcStyleHero; // Green, HERO (32) — large numbers +static lv_style_t fcStyleBody; // White, MD (18) — body text +static lv_style_t fcStyleLabel; // Gray, SM (16) — secondary labels +static lv_style_t fcStyleWarn; // Orange, MD (18) — warnings +static lv_style_t fcStyleError; // Red, MD (18) — errors +uint32_t touchPressCount = 0; +uint32_t touchReleaseCount = 0; +bool touchWasPressed = false; +int32_t lastTouchX = -1; // Last LVGL-space touch X (for diagnostics) +int32_t lastTouchY = -1; // Last LVGL-space touch Y (for diagnostics) + +// LVGL needs a tick source to track elapsed time for animations/timers. +// On ESP32-S3, esp_timer_get_time() returns microseconds since boot. +uint32_t lvglTickCb(void) { + return (uint32_t)(esp_timer_get_time() / 1000ULL); // Convert µs to ms +} + +// Called by LVGL when a rendered region is ready to be sent to the display. +// Uses TFT_eSPI's SPI transaction-safe pushColors with byte swap. +void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + uint32_t w = (area->x2 - area->x1 + 1); + uint32_t h = (area->y2 - area->y1 + 1); + + tft.startWrite(); + tft.setAddrWindow(area->x1, area->y1, w, h); + // swap=true: byte-swaps from LVGL native little-endian to ST7796U big-endian + tft.pushColors((uint16_t*)px_map, w * h, true); + tft.endWrite(); + + lv_display_flush_ready(disp); +} + +void lvglLogCb(lv_log_level_t level, const char* buf) { + LV_UNUSED(level); + Serial.println(buf); + Serial.flush(); +} + +void lvglTouchReadCb(lv_indev_t* indev, lv_indev_data_t* data) { + (void)indev; + + if (!touchAvailable) { + data->state = LV_INDEV_STATE_RELEASED; + return; + } + + if (ctp.touched()) { + TS_Point p = ctp.getPoint(); + // Same coordinate transform as legacy pipeline: + data->point.x = (int32_t)(480 - p.y); // horizontal 0-479 + data->point.y = (int32_t)(p.x); // vertical 0-319 + data->state = LV_INDEV_STATE_PRESSED; + lastTouchX = data->point.x; + lastTouchY = data->point.y; + lastActivityTime = millis(); // DIAG: keep TFT awake on LVGL screens too + if (!touchWasPressed) { + touchPressCount++; + touchWasPressed = true; + logPrintf("[TOUCH] PRESS @(%ld,%ld) scr=%d sub=%d\n", + data->point.x, data->point.y, currentScreen, settingsSubScreen); + } + } else { + data->state = LV_INDEV_STATE_RELEASED; + if (touchWasPressed) { + touchReleaseCount++; + touchWasPressed = false; + // DIAG: log what LVGL thinks is pressed at touch coordinates + lv_obj_t* hit = lv_indev_search_obj(lv_screen_active(), &data->point); + logPrintf("[TOUCH] RELEASE #%lu → hit_obj=%p (scr=%d sub=%d)\n", + touchPressCount, (void*)hit, currentScreen, settingsSubScreen); + } + } +} + +// Maps A/B/C buttons to LVGL encoder: A=prev(-1), B=next(+1), C=enter. +// Edge detection emits a single enc_diff pulse per press. +void lvglEncoderReadCb(lv_indev_t* indev, lv_indev_data_t* data) { + (void)indev; + + static bool prevA = false, prevB = false, prevC = false; + + bool curA = !digitalRead(BUTTON_A); // active LOW + bool curB = !digitalRead(BUTTON_B); + bool curC = !digitalRead(BUTTON_C); + + int16_t diff = 0; + + // Edge detect: fire once on press-down + if (curA && !prevA) diff = -1; // A = previous + if (curB && !prevB) diff = +1; // B = next + + data->enc_diff = diff; + data->state = curC ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; + + prevA = curA; + prevB = curB; + prevC = curC; +} + +void initFCTheme() { + // Initialize default dark theme with cyan primary, green secondary + lv_theme_t* theme = lv_theme_default_init( + lvglDisplay, + FC_COLOR_HEADER, // primary — focus rings, active elements + FC_COLOR_VALUE, // secondary — accents, toggles + true, // dark mode + FC_FONT_MD // default app font = 18px Montserrat + ); + lv_display_set_theme(lvglDisplay, theme); + + // Screen background: black + lv_obj_set_style_bg_color(lv_screen_active(), FC_COLOR_BG, 0); + + // Initialize named styles + lv_style_init(&fcStyleHeader); + lv_style_set_text_color(&fcStyleHeader, FC_COLOR_HEADER); + lv_style_set_text_font(&fcStyleHeader, FC_FONT_XL); + + lv_style_init(&fcStyleValue); + lv_style_set_text_color(&fcStyleValue, FC_COLOR_VALUE); + lv_style_set_text_font(&fcStyleValue, FC_FONT_LG); + + lv_style_init(&fcStyleHero); + lv_style_set_text_color(&fcStyleHero, FC_COLOR_VALUE); + lv_style_set_text_font(&fcStyleHero, FC_FONT_HERO); + + lv_style_init(&fcStyleBody); + lv_style_set_text_color(&fcStyleBody, FC_COLOR_TEXT); + lv_style_set_text_font(&fcStyleBody, FC_FONT_MD); + + lv_style_init(&fcStyleLabel); + lv_style_set_text_color(&fcStyleLabel, FC_COLOR_DIM); + lv_style_set_text_font(&fcStyleLabel, FC_FONT_SM); + + lv_style_init(&fcStyleWarn); + lv_style_set_text_color(&fcStyleWarn, FC_COLOR_WARN); + lv_style_set_text_font(&fcStyleWarn, FC_FONT_MD); + + lv_style_init(&fcStyleError); + lv_style_set_text_color(&fcStyleError, FC_COLOR_ERROR); + lv_style_set_text_font(&fcStyleError, FC_FONT_MD); + + logPrintln("[LVGL] Field Compass theme initialized (7 styles)"); +} diff --git a/src/lvgl_port.h b/src/lvgl_port.h new file mode 100644 index 0000000..cf75e33 --- /dev/null +++ b/src/lvgl_port.h @@ -0,0 +1,26 @@ +#pragma once +// lvgl_port.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include +#include + +#define LVGL_TEST_MODE 0 + +extern lv_display_t* lvglDisplay; +extern uint8_t* lvglBuf1; +extern uint8_t* lvglBuf2; +extern bool lvglAvailable; +extern lv_indev_t* lvglTouchIndev; +extern lv_indev_t* lvglEncoderIndev; +extern lv_group_t* lvglGroup; +extern int32_t lastTouchX; +extern int32_t lastTouchY; +extern uint32_t touchPressCount; +extern uint32_t touchReleaseCount; +extern bool touchWasPressed; + +uint32_t lvglTickCb(void); +void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map); +void lvglLogCb(lv_log_level_t level, const char* buf); +void lvglTouchReadCb(lv_indev_t* indev, lv_indev_data_t* data); +void lvglEncoderReadCb(lv_indev_t* indev, lv_indev_data_t* data); +void initFCTheme(); diff --git a/src/src.ino b/src/src.ino index a6e9489..631b418 100644 --- a/src/src.ino +++ b/src/src.ino @@ -70,41 +70,12 @@ #include "ui_state.h" #include "touch.h" #include "web.h" +#include "lvgl_port.h" -// Set to 1 to enable LVGL test rendering (label in corner during boot). -// Set to 0 for normal operation where sprite pipeline handles all rendering. -#define LVGL_TEST_MODE 0 - -// LVGL display object and draw buffers (PSRAM-backed for performance) -static lv_display_t* lvglDisplay = NULL; - -// Two 480x50 partial-render buffers in PSRAM (~48KB each, 96KB total) -// Allocated dynamically in initLVGL() via heap_caps_malloc(MALLOC_CAP_SPIRAM); -// LVGL_BUF_LINES / LVGL_BUF_SIZE are in display.h (the diagnostics page shows them) -static uint8_t* lvglBuf1 = NULL; -static uint8_t* lvglBuf2 = NULL; - -// LVGL initialization flag -static bool lvglAvailable = false; - -// LVGL input devices (#106) -static lv_indev_t* lvglTouchIndev = NULL; // FT6336U → LV_INDEV_TYPE_POINTER -static lv_indev_t* lvglEncoderIndev = NULL; // Buttons → LV_INDEV_TYPE_ENCODER -static lv_group_t* lvglGroup = NULL; // Focus group for encoder navigation - -// LVGL named styles — Field Compass theme (#107) -static lv_style_t fcStyleHeader; // Cyan, XL (24) — screen titles -static lv_style_t fcStyleValue; // Green, LG (20) — sensor values -static lv_style_t fcStyleHero; // Green, HERO (32) — large numbers -static lv_style_t fcStyleBody; // White, MD (18) — body text -static lv_style_t fcStyleLabel; // Gray, SM (16) — secondary labels -static lv_style_t fcStyleWarn; // Orange, MD (18) — warnings -static lv_style_t fcStyleError; // Red, MD (18) — errors // ============== Configuration ============== - // TFT Display pins (ST7796U 3.5" IPS, SPI) — TFT_CS=18, TFT_DC=17, TFT_RST=16 are // defined in platformio.ini build_flags under USER_SETUP_LOADED=1, which tells // TFT_eSPI to skip its own header selection entirely. Not User_Setup.h (#184). @@ -113,14 +84,6 @@ static lv_style_t fcStyleError; // Red, MD (18) — errors // FRAM Memory Map (256KB = 262,144 bytes, MB85RS2MTA) -// SPI pins (explicit definition for PSRAM variant compatibility) -// Adafruit ESP32-S3 Feather default SPI pins - -// Button pins (directly wired, active LOW) -#define BUTTON_A 9 -#define BUTTON_B 6 -#define BUTTON_C 5 - // Screen dimensions (landscape mode after rotation) @@ -186,113 +149,18 @@ bool buttonCLongPressHandled = false; // ============== LVGL Tick Callback ============== -// LVGL needs a tick source to track elapsed time for animations/timers. -// On ESP32-S3, esp_timer_get_time() returns microseconds since boot. -static uint32_t lvglTickCb(void) { - return (uint32_t)(esp_timer_get_time() / 1000ULL); // Convert µs to ms -} - // ============== LVGL Flush Callback ============== -// Called by LVGL when a rendered region is ready to be sent to the display. -// Uses TFT_eSPI's SPI transaction-safe pushColors with byte swap. -void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { - uint32_t w = (area->x2 - area->x1 + 1); - uint32_t h = (area->y2 - area->y1 + 1); - - tft.startWrite(); - tft.setAddrWindow(area->x1, area->y1, w, h); - // swap=true: byte-swaps from LVGL native little-endian to ST7796U big-endian - tft.pushColors((uint16_t*)px_map, w * h, true); - tft.endWrite(); - - lv_display_flush_ready(disp); -} - // ============== LVGL Log Callback ============== #if LV_USE_LOG != 0 -void lvglLogCb(lv_log_level_t level, const char* buf) { - LV_UNUSED(level); - Serial.println(buf); - Serial.flush(); -} #endif // ============== LVGL Touch Read Callback (#106) ============== -// Called by LVGL's indev timer (~33ms). Polls FT6336U over I2C. -// I2C reads are non-destructive — both legacy and LVGL read the same hardware. -// Touch diagnostics: track press/release transitions for debugging (#112) -static uint32_t touchPressCount = 0; -static uint32_t touchReleaseCount = 0; -static bool touchWasPressed = false; -static int32_t lastTouchX = -1; // Last LVGL-space touch X (for diagnostics) -static int32_t lastTouchY = -1; // Last LVGL-space touch Y (for diagnostics) - -void lvglTouchReadCb(lv_indev_t* indev, lv_indev_data_t* data) { - (void)indev; - - if (!touchAvailable) { - data->state = LV_INDEV_STATE_RELEASED; - return; - } - - if (ctp.touched()) { - TS_Point p = ctp.getPoint(); - // Same coordinate transform as legacy pipeline: - data->point.x = (int32_t)(480 - p.y); // horizontal 0-479 - data->point.y = (int32_t)(p.x); // vertical 0-319 - data->state = LV_INDEV_STATE_PRESSED; - lastTouchX = data->point.x; - lastTouchY = data->point.y; - lastActivityTime = millis(); // DIAG: keep TFT awake on LVGL screens too - if (!touchWasPressed) { - touchPressCount++; - touchWasPressed = true; - logPrintf("[TOUCH] PRESS @(%ld,%ld) scr=%d sub=%d\n", - data->point.x, data->point.y, currentScreen, settingsSubScreen); - } - } else { - data->state = LV_INDEV_STATE_RELEASED; - if (touchWasPressed) { - touchReleaseCount++; - touchWasPressed = false; - // DIAG: log what LVGL thinks is pressed at touch coordinates - lv_obj_t* hit = lv_indev_search_obj(lv_screen_active(), &data->point); - logPrintf("[TOUCH] RELEASE #%lu → hit_obj=%p (scr=%d sub=%d)\n", - touchPressCount, (void*)hit, currentScreen, settingsSubScreen); - } - } -} // ============== LVGL Encoder Read Callback (#106) ============== -// Maps A/B/C buttons to LVGL encoder: A=prev(-1), B=next(+1), C=enter. -// Edge detection emits a single enc_diff pulse per press. -void lvglEncoderReadCb(lv_indev_t* indev, lv_indev_data_t* data) { - (void)indev; - - static bool prevA = false, prevB = false, prevC = false; - - bool curA = !digitalRead(BUTTON_A); // active LOW - bool curB = !digitalRead(BUTTON_B); - bool curC = !digitalRead(BUTTON_C); - - int16_t diff = 0; - - // Edge detect: fire once on press-down - if (curA && !prevA) diff = -1; // A = previous - if (curB && !prevB) diff = +1; // B = next - - data->enc_diff = diff; - data->state = curC ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; - - prevA = curA; - prevB = curB; - prevC = curC; -} - // ============== Setup ============== void setup() { @@ -595,52 +463,6 @@ void scanI2C() { } // ============== Field Compass LVGL Theme (#107) ============== -void initFCTheme() { - // Initialize default dark theme with cyan primary, green secondary - lv_theme_t* theme = lv_theme_default_init( - lvglDisplay, - FC_COLOR_HEADER, // primary — focus rings, active elements - FC_COLOR_VALUE, // secondary — accents, toggles - true, // dark mode - FC_FONT_MD // default app font = 18px Montserrat - ); - lv_display_set_theme(lvglDisplay, theme); - - // Screen background: black - lv_obj_set_style_bg_color(lv_screen_active(), FC_COLOR_BG, 0); - - // Initialize named styles - lv_style_init(&fcStyleHeader); - lv_style_set_text_color(&fcStyleHeader, FC_COLOR_HEADER); - lv_style_set_text_font(&fcStyleHeader, FC_FONT_XL); - - lv_style_init(&fcStyleValue); - lv_style_set_text_color(&fcStyleValue, FC_COLOR_VALUE); - lv_style_set_text_font(&fcStyleValue, FC_FONT_LG); - - lv_style_init(&fcStyleHero); - lv_style_set_text_color(&fcStyleHero, FC_COLOR_VALUE); - lv_style_set_text_font(&fcStyleHero, FC_FONT_HERO); - - lv_style_init(&fcStyleBody); - lv_style_set_text_color(&fcStyleBody, FC_COLOR_TEXT); - lv_style_set_text_font(&fcStyleBody, FC_FONT_MD); - - lv_style_init(&fcStyleLabel); - lv_style_set_text_color(&fcStyleLabel, FC_COLOR_DIM); - lv_style_set_text_font(&fcStyleLabel, FC_FONT_SM); - - lv_style_init(&fcStyleWarn); - lv_style_set_text_color(&fcStyleWarn, FC_COLOR_WARN); - lv_style_set_text_font(&fcStyleWarn, FC_FONT_MD); - - lv_style_init(&fcStyleError); - lv_style_set_text_color(&fcStyleError, FC_COLOR_ERROR); - lv_style_set_text_font(&fcStyleError, FC_FONT_MD); - - logPrintln("[LVGL] Field Compass theme initialized (7 styles)"); -} - // ============== FC Widget Library (#108) ============== // Forward declarations for callbacks used in widgets (#113) From 6ec9fe6a1d086a792e6f53d205b99c65361950bd Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 19:27:26 -0400 Subject: [PATCH 2/4] refactor(#265): E4 band 4, unit 2 -- oled_screens leaves src.ino src/oled_screens.{h,cpp}: updateOLED and the five drawOLED* functions. No state of their own: they render the published domain state on the oled object through eleven headers. Harness: the existing-prototype matcher now requires type words at column 0 and nothing after the semicolon -- the dry run for the next unit showed it matching `case SCREEN_COMPASS: updateCompassData(); ...; break;` inside updateDisplay, which it would have deleted as a redundant prototype. Caught before anything was written. Pointer-to-pointer variable types are accepted. Verified by scripts/verify_extraction.py, PASS: 592 -> 592 symbols, 6 relocated, no linkage change, 1 size delta (-1), 6 moved functions checked by reference set, unexplained none. .flash.text +60 (six calls now cross-TU: literal pools and relaxation), .flash.rodata -8, RAM unchanged. Flash 1,856,670 -> 1,856,722. src.ino 4,209 -> 3,993. --- scripts/extract_unit.py | 8 +- src/oled_screens.cpp | 230 ++++++++++++++++++++++++++++++++++++++++ src/oled_screens.h | 10 ++ src/src.ino | 218 +------------------------------------ 4 files changed, 247 insertions(+), 219 deletions(-) create mode 100644 src/oled_screens.cpp create mode 100644 src/oled_screens.h diff --git a/scripts/extract_unit.py b/scripts/extract_unit.py index 5354459..95c8e1f 100644 --- a/scripts/extract_unit.py +++ b/scripts/extract_unit.py @@ -129,7 +129,7 @@ def is_static(lines: list[str], start: int) -> bool: # in `*`. The name follows, optionally an array suffix, then `=`, `;` or `,`. # Column 0 only: file-scope declarations are, and an indented # `else gprmcFixThisCycle = true;` reads as a declaration otherwise (band 3b). -VAR_RE_TMPL = r"^(?:static\s+)?(?:volatile\s+)?(?:const\s+)?(?:[A-Za-z_][\w:<>]*\*?\s+)+\**{name}\s*(?:\[[^\]]*\])*\s*(?:=|;|,|\()" +VAR_RE_TMPL = r"^(?:static\s+)?(?:volatile\s+)?(?:const\s+)?(?:[A-Za-z_][\w:<>]*\**\s+)+\**{name}\s*(?:\[[^\]]*\])*\s*(?:=|;|,|\()" # the trailing `(` is a constructor-call declaration, `WebServer webServer(WEB_SERVER_PORT);` # (band 3e); a prototype never collides because variables are looked up by name @@ -322,7 +322,11 @@ def main() -> int: for c, d, b, e, n, st in moves: if st: continue - pat = re.compile(r"^\s*(?:static\s+)?[A-Za-z_][\w\s\*&:<>,]*\s\**" + re.escape(n) + r"\s*\([^;{]*\)\s*;") + # a prototype: type words at column 0, the name, a parameter list, `;` and + # nothing else. The first version accepted any prefix and matched + # `case SCREEN_COMPASS: updateCompassData(); ...; break;` (band 4) + pat = re.compile(r"^(?:static\s+)?(?:[A-Za-z_][\w:<>]*[\s*&]+)+" + re.escape(n) + + r"\s*\([^;{]*\)\s*;\s*(?://.*)?$") for i, l in enumerate(lines): if i != d and pat.match(l): protos[i] = (n, re.sub(r"\s*//.*$", "", l.rstrip("\r\n")).strip()) diff --git a/src/oled_screens.cpp b/src/oled_screens.cpp new file mode 100644 index 0000000..6f3801f --- /dev/null +++ b/src/oled_screens.cpp @@ -0,0 +1,230 @@ +// oled_screens.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "oled_screens.h" +#include "oled.h" +#include "ui_state.h" +#include "gps.h" +#include "imu.h" +#include "env.h" +#include "weather.h" +#include "geocache.h" +#include "settings.h" +#include "geo.h" +#include "logging.h" + +void updateOLED() { + oled.clearDisplay(); + + // Show different content based on current screen (mirroring TFT) + switch (currentScreen) { + case SCREEN_COMPASS: + drawOLEDScreenCompass(); + break; + case SCREEN_GEOCACHE: + drawOLEDScreenGeocache(); + break; + case SCREEN_ENV: + drawOLEDScreenEnv(); + break; + case SCREEN_TELEMETRY: + drawOLEDScreenTelemetry(); + break; + case SCREEN_SETTINGS: + oled.setTextSize(1); + oled.setCursor(0, 0); + oled.print("SETTINGS"); + break; + } + + // Draw screen indicator + drawOLEDNavBar(); + + oled.display(); +} + +// OLED Telemetry — combined GPS + IMU (#97) +void drawOLEDScreenTelemetry() { + char buf[32]; + + oled.setTextSize(1); + oled.setCursor(0, 0); + oled.print("TELEMETRY"); + + // GPS section (rows 1-3) + if (gpsData.valid) { + oled.setCursor(0, 10); + sprintf(buf, "%.5f %c", fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S'); + oled.print(buf); + + oled.setCursor(0, 20); + sprintf(buf, "%.5f %c", fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W'); + oled.print(buf); + + float altVal = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; + char altUnit = useMetricUnits ? 'm' : 'f'; + oled.setCursor(0, 30); + sprintf(buf, "%.0f%c S:%d H:%.1f", altVal, altUnit, gpsData.satellites, gpsData.hdop); + oled.print(buf); + } else if (gpsData.receiving) { + oled.setCursor(0, 15); + oled.print("Acquiring fix..."); + oled.setCursor(0, 25); + sprintf(buf, "Sat: %d", gpsData.satellites); + oled.print(buf); + } else { + oled.setCursor(0, 18); + oled.print("No GPS data"); + } + + // IMU section (rows 4-5) + if (imuAvailable && magAvailable) { + oled.setCursor(0, 42); + sprintf(buf, "%.0f%s R:%.0f P:%.0f", imuData.heading, getCardinal(imuData.heading), imuData.roll, imuData.pitch); + oled.print(buf); + + oled.setCursor(0, 52); + sprintf(buf, "Accel:%.2f m/s2", imuData.accelMag); + oled.print(buf); + } else { + oled.setCursor(0, 46); + oled.print("No IMU"); + } +} + +void drawOLEDScreenEnv() { + char buf[32]; + + oled.setTextSize(1); + oled.setCursor(0, 0); + oled.print("ENVIRONMENT"); + + if (bmeAvailable || shtAvailable) { + // SHT41 temp/humidity preferred over BME688 (#48) + float tempC = shtAvailable ? shtData.temperature : envData.temperature; + float tempF = tempC * 9.0 / 5.0 + 32.0; + float humid = shtAvailable ? shtData.humidity : envData.humidity; + + oled.setCursor(0, 10); + if (useFahrenheit) + sprintf(buf, "%.1fF %.1f%% IAQ:%.0f", tempF, humid, envData.iaq); + else + sprintf(buf, "%.1fC %.1f%% IAQ:%.0f", tempC, humid, envData.iaq); + oled.print(buf); + + oled.setCursor(0, 22); + sprintf(buf, "%.0fhPa %.2f\"", envData.pressure, hPaToInHg(envData.pressure)); + oled.print(buf); + + oled.setCursor(0, 34); + sprintf(buf, "%s %s", getTrendArrow(), weatherTrend.forecast); + oled.print(buf); + + oled.setCursor(0, 46); + sprintf(buf, "CO2:%.0f %s", envData.co2Equivalent, shtAvailable ? "SHT" : "BME"); + oled.print(buf); + } else { + oled.setCursor(0, 28); + oled.print("No env sensors"); + } +} + +void drawOLEDScreenCompass() { + char buf[32]; + + oled.setTextSize(1); + oled.setCursor(0, 0); + oled.print("COMPASS"); + + if (imuAvailable && magAvailable) { + oled.setTextSize(2); + oled.setCursor(0, 12); + sprintf(buf, "%.0f %s", imuData.heading, getCardinal(imuData.heading)); + oled.print(buf); + + oled.setTextSize(1); + oled.setCursor(0, 36); + if (gpsData.valid) { + sprintf(buf, "%.1f mph", gpsData.speedKnots * 1.15078); + } else { + sprintf(buf, "-- mph"); + } + oled.print(buf); + } else { + oled.setCursor(0, 16); + oled.print("No IMU"); + } +} + +void drawOLEDScreenGeocache() { + char buf[32]; + + oled.setTextSize(1); + oled.setCursor(0, 0); + oled.print("GEOCACHE"); + + // Check if we have any caches and if selected cache is valid + if (cacheListCount == 0 || selectedCacheIndex >= cacheListCount || !cacheList[selectedCacheIndex].valid) { + oled.setCursor(0, 16); + oled.print("No cache"); + return; + } + + // Get reference to selected cache + GeocacheEntry& cache = cacheList[selectedCacheIndex]; + + // Distance and bearing + float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + float bearing = calcBearing(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + + oled.setCursor(0, 12); + if (useMetricUnits) { + if (distKm >= 1.0) { + sprintf(buf, "%.1fkm %d%c", distKm, (int)bearing, 247); + } else { + sprintf(buf, "%.0fm %d%c", distKm * 1000, (int)bearing, 247); + } + } else { + float distMi = distKm * 0.621371; + float distFt = distKm * 3280.84; + if (distMi >= 1.0) { + sprintf(buf, "%.1fmi %d%c", distMi, (int)bearing, 247); + } else { + sprintf(buf, "%.0fft %d%c", distFt, (int)bearing, 247); + } + } + oled.print(buf); + + // Arrow direction + float arrowAngle = bearing - imuData.heading; + if (arrowAngle < 0) arrowAngle += 360; + if (arrowAngle >= 360) arrowAngle -= 360; + + oled.setCursor(0, 24); + sprintf(buf, "Arrow: %.0f%c", arrowAngle, 247); + oled.print(buf); + + // D/T rating + oled.setCursor(0, 36); + sprintf(buf, "D:%.1f T:%.1f", cache.difficulty, cache.terrain); + oled.print(buf); + + // Accuracy + float accM = getGpsAccuracyMeters(); + oled.setCursor(0, 48); + if (useMetricUnits) { + sprintf(buf, "+/-%.0fm", accM); + } else { + sprintf(buf, "+/-%.0fft", accM * 3.28084); + } + oled.print(buf); +} + +void drawOLEDNavBar() { + // Draw screen number indicator at bottom right + oled.setCursor(100, 56); + oled.setTextSize(1); + char buf[8]; + sprintf(buf, "[%d/%d]", currentScreen + 1, NUM_SCREENS); + oled.print(buf); +} diff --git a/src/oled_screens.h b/src/oled_screens.h new file mode 100644 index 0000000..69451b1 --- /dev/null +++ b/src/oled_screens.h @@ -0,0 +1,10 @@ +#pragma once +// oled_screens.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +void updateOLED(); +void drawOLEDScreenTelemetry(); +void drawOLEDScreenEnv(); +void drawOLEDScreenCompass(); +void drawOLEDScreenGeocache(); +void drawOLEDNavBar(); diff --git a/src/src.ino b/src/src.ino index 631b418..bd0ed57 100644 --- a/src/src.ino +++ b/src/src.ino @@ -71,6 +71,7 @@ #include "touch.h" #include "web.h" #include "lvgl_port.h" +#include "oled_screens.h" // ============== Configuration ============== @@ -3984,226 +3985,9 @@ void updateDisplay() { // ============== OLED Display Functions ============== -void updateOLED() { - oled.clearDisplay(); - - // Show different content based on current screen (mirroring TFT) - switch (currentScreen) { - case SCREEN_COMPASS: - drawOLEDScreenCompass(); - break; - case SCREEN_GEOCACHE: - drawOLEDScreenGeocache(); - break; - case SCREEN_ENV: - drawOLEDScreenEnv(); - break; - case SCREEN_TELEMETRY: - drawOLEDScreenTelemetry(); - break; - case SCREEN_SETTINGS: - oled.setTextSize(1); - oled.setCursor(0, 0); - oled.print("SETTINGS"); - break; - } - - // Draw screen indicator - drawOLEDNavBar(); - - oled.display(); -} - // drawOLEDScreenOps() removed — content now in Settings > About (#102) -// OLED Telemetry — combined GPS + IMU (#97) -void drawOLEDScreenTelemetry() { - char buf[32]; - - oled.setTextSize(1); - oled.setCursor(0, 0); - oled.print("TELEMETRY"); - - // GPS section (rows 1-3) - if (gpsData.valid) { - oled.setCursor(0, 10); - sprintf(buf, "%.5f %c", fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S'); - oled.print(buf); - - oled.setCursor(0, 20); - sprintf(buf, "%.5f %c", fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W'); - oled.print(buf); - - float altVal = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; - char altUnit = useMetricUnits ? 'm' : 'f'; - oled.setCursor(0, 30); - sprintf(buf, "%.0f%c S:%d H:%.1f", altVal, altUnit, gpsData.satellites, gpsData.hdop); - oled.print(buf); - } else if (gpsData.receiving) { - oled.setCursor(0, 15); - oled.print("Acquiring fix..."); - oled.setCursor(0, 25); - sprintf(buf, "Sat: %d", gpsData.satellites); - oled.print(buf); - } else { - oled.setCursor(0, 18); - oled.print("No GPS data"); - } - - // IMU section (rows 4-5) - if (imuAvailable && magAvailable) { - oled.setCursor(0, 42); - sprintf(buf, "%.0f%s R:%.0f P:%.0f", imuData.heading, getCardinal(imuData.heading), imuData.roll, imuData.pitch); - oled.print(buf); - - oled.setCursor(0, 52); - sprintf(buf, "Accel:%.2f m/s2", imuData.accelMag); - oled.print(buf); - } else { - oled.setCursor(0, 46); - oled.print("No IMU"); - } -} - -void drawOLEDScreenEnv() { - char buf[32]; - - oled.setTextSize(1); - oled.setCursor(0, 0); - oled.print("ENVIRONMENT"); - - if (bmeAvailable || shtAvailable) { - // SHT41 temp/humidity preferred over BME688 (#48) - float tempC = shtAvailable ? shtData.temperature : envData.temperature; - float tempF = tempC * 9.0 / 5.0 + 32.0; - float humid = shtAvailable ? shtData.humidity : envData.humidity; - - oled.setCursor(0, 10); - if (useFahrenheit) - sprintf(buf, "%.1fF %.1f%% IAQ:%.0f", tempF, humid, envData.iaq); - else - sprintf(buf, "%.1fC %.1f%% IAQ:%.0f", tempC, humid, envData.iaq); - oled.print(buf); - - oled.setCursor(0, 22); - sprintf(buf, "%.0fhPa %.2f\"", envData.pressure, hPaToInHg(envData.pressure)); - oled.print(buf); - - oled.setCursor(0, 34); - sprintf(buf, "%s %s", getTrendArrow(), weatherTrend.forecast); - oled.print(buf); - - oled.setCursor(0, 46); - sprintf(buf, "CO2:%.0f %s", envData.co2Equivalent, shtAvailable ? "SHT" : "BME"); - oled.print(buf); - } else { - oled.setCursor(0, 28); - oled.print("No env sensors"); - } -} - -void drawOLEDScreenCompass() { - char buf[32]; - - oled.setTextSize(1); - oled.setCursor(0, 0); - oled.print("COMPASS"); - - if (imuAvailable && magAvailable) { - oled.setTextSize(2); - oled.setCursor(0, 12); - sprintf(buf, "%.0f %s", imuData.heading, getCardinal(imuData.heading)); - oled.print(buf); - - oled.setTextSize(1); - oled.setCursor(0, 36); - if (gpsData.valid) { - sprintf(buf, "%.1f mph", gpsData.speedKnots * 1.15078); - } else { - sprintf(buf, "-- mph"); - } - oled.print(buf); - } else { - oled.setCursor(0, 16); - oled.print("No IMU"); - } -} - // ============== OLED Geocache Screen (#70) ============== -void drawOLEDScreenGeocache() { - char buf[32]; - - oled.setTextSize(1); - oled.setCursor(0, 0); - oled.print("GEOCACHE"); - - // Check if we have any caches and if selected cache is valid - if (cacheListCount == 0 || selectedCacheIndex >= cacheListCount || !cacheList[selectedCacheIndex].valid) { - oled.setCursor(0, 16); - oled.print("No cache"); - return; - } - - // Get reference to selected cache - GeocacheEntry& cache = cacheList[selectedCacheIndex]; - - // Distance and bearing - float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - float bearing = calcBearing(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - - oled.setCursor(0, 12); - if (useMetricUnits) { - if (distKm >= 1.0) { - sprintf(buf, "%.1fkm %d%c", distKm, (int)bearing, 247); - } else { - sprintf(buf, "%.0fm %d%c", distKm * 1000, (int)bearing, 247); - } - } else { - float distMi = distKm * 0.621371; - float distFt = distKm * 3280.84; - if (distMi >= 1.0) { - sprintf(buf, "%.1fmi %d%c", distMi, (int)bearing, 247); - } else { - sprintf(buf, "%.0fft %d%c", distFt, (int)bearing, 247); - } - } - oled.print(buf); - - // Arrow direction - float arrowAngle = bearing - imuData.heading; - if (arrowAngle < 0) arrowAngle += 360; - if (arrowAngle >= 360) arrowAngle -= 360; - - oled.setCursor(0, 24); - sprintf(buf, "Arrow: %.0f%c", arrowAngle, 247); - oled.print(buf); - - // D/T rating - oled.setCursor(0, 36); - sprintf(buf, "D:%.1f T:%.1f", cache.difficulty, cache.terrain); - oled.print(buf); - - // Accuracy - float accM = getGpsAccuracyMeters(); - oled.setCursor(0, 48); - if (useMetricUnits) { - sprintf(buf, "+/-%.0fm", accM); - } else { - sprintf(buf, "+/-%.0fft", accM * 3.28084); - } - oled.print(buf); -} - // drawOLEDScreenDiags() removed — diagnostics now in Settings (#90) -void drawOLEDNavBar() { - // Draw screen number indicator at bottom right - oled.setCursor(100, 56); - oled.setTextSize(1); - char buf[8]; - sprintf(buf, "[%d/%d]", currentScreen + 1, NUM_SCREENS); - oled.print(buf); -} From b6f226161202254ea8b8e5b77ab4abc59e580cb2 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 19:37:18 -0400 Subject: [PATCH 3/4] refactor(#265): E4 band 4, unit 3 -- the five LVGL screens and navigation leave src.ino Six units in one commit, because the screens and the navigation reference each other through their headers (a screen registers screenGestureCb and creates its header with fcHeaderCreate; navigation loads the screens): any one of them alone would have reached back into src.ino. src/screen_compass.{h,cpp} builder, rose draw callback, data update src/screen_env.{h,cpp} builder, data update src/screen_telemetry.{h,cpp} builder, row helpers, data update src/screen_geocache.{h,cpp} builder, nav draw callback, data update, the nine filter callbacks, the button handler src/screen_settings.{h,cpp} builder, data update, 21 callbacks, the settings scroll helper, logLvglHeap src/navigation.{h,cpp} navigate*, the gesture and gear callbacks, fcHeaderCreate, the list picker, updateSDIndicators, updateDisplay, mainScreens Each screen publishes its screen object (navigation's mainScreens table and initLVGL load them) and its header (updateSDIndicators). Six statics the graph could not see -- two write-only headers, two const-folded counts, an inlined colour helper, the settings menu buttons -- moved by hand and are so marked. web.h restores the fs::FS alias WebServer.h relies on: TFT_eSPI defines FS_NO_GLOBALS, which src.ino's include order used to hide. Harness: static functions get forward declarations at the top of their unit and lose their file-scope prototypes in src.ino; the existing-prototype matcher no longer matches a `case X: fn(); ...` line. Verifier: compiler clones of library inlines compare as the original and are tolerated. Verified by scripts/verify_extraction.py, PASS: 592 -> 593 symbols (a constprop clone of lv_obj_set_style_pad_all in the smaller telemetry TU), 213 relocated, 12 linkage changes all declared, 6 size deltas net +104 all attributed, 61 moved functions checked by reference set (27 references now cross-TU, every other kept), unexplained none. .flash.text +184 (inputs +182: .text +110, literals +72), .flash.rodata -96, RAM unchanged. Flash 1,856,722 -> 1,856,810. src.ino 3,993 -> 804. --- scripts/extract_unit.py | 10 +- scripts/verify_extraction.py | 16 +- src/navigation.cpp | 268 +++ src/navigation.h | 12 + src/screen_compass.cpp | 420 +++++ src/screen_compass.h | 10 + src/screen_env.cpp | 198 +++ src/screen_env.h | 9 + src/screen_geocache.cpp | 934 ++++++++++ src/screen_geocache.h | 11 + src/screen_settings.cpp | 1038 +++++++++++ src/screen_settings.h | 12 + src/screen_telemetry.cpp | 320 ++++ src/screen_telemetry.h | 9 + src/src.ino | 3201 +--------------------------------- src/web.h | 6 + 16 files changed, 3275 insertions(+), 3199 deletions(-) create mode 100644 src/navigation.cpp create mode 100644 src/navigation.h create mode 100644 src/screen_compass.cpp create mode 100644 src/screen_compass.h create mode 100644 src/screen_env.cpp create mode 100644 src/screen_env.h create mode 100644 src/screen_geocache.cpp create mode 100644 src/screen_geocache.h create mode 100644 src/screen_settings.cpp create mode 100644 src/screen_settings.h create mode 100644 src/screen_telemetry.cpp create mode 100644 src/screen_telemetry.h diff --git a/scripts/extract_unit.py b/scripts/extract_unit.py index 95c8e1f..b0cbf07 100644 --- a/scripts/extract_unit.py +++ b/scripts/extract_unit.py @@ -320,8 +320,8 @@ def main() -> int: # existing standalone prototypes of moved functions: reuse text, drop line protos = {} # line index -> (name, text) for c, d, b, e, n, st in moves: - if st: - continue + # static functions included: their file-scope prototypes leave src.ino + # too, and the unit declares them up front (see the cpp emission) # a prototype: type words at column 0, the name, a parameter list, `;` and # nothing else. The first version accepted any prefix and matched # `case SCREEN_COMPASS: updateCompassData(); ...; break;` (band 4) @@ -407,6 +407,12 @@ def inc_line(inc: str) -> str: l = lines[i] cpp.append(re.sub(r"^(\s*)static\s+", r"\1", l) if n in pvars else l) cpp.extend(lines[i + 1:var_ends[n] + 1]) + statics = [prototype(lines, d, b) for c, d, b, e, n, st in moves if st] + if statics: + # a builder registers callbacks defined after it (band 4): declare + # every static function of the unit up front, as src.ino did at file scope + cpp.append(nl) + cpp.extend(p + nl for p in statics) for c, d, b, e, n, st in moves: cpp.append(nl) for i in range(c, e + 1): diff --git a/scripts/verify_extraction.py b/scripts/verify_extraction.py index 7a373fb..42abd4c 100644 --- a/scripts/verify_extraction.py +++ b/scripts/verify_extraction.py @@ -51,6 +51,7 @@ import importlib.util import json import pathlib +import re import subprocess import sys @@ -144,9 +145,14 @@ def main() -> int: def dm(s: str) -> str: return names.get(s, s) + CLONE = re.compile(r"\$(?:constprop|isra|part)\$\d+$") + def norm(s: str) -> str: - """Demangled name, collapsed to the base name for re-signed functions.""" - d = dm(s) + """Demangled name, collapsed to the base name for re-signed functions, + and to the original function for a compiler clone: a smaller TU makes + GCC specialise a library inline for one caller (band 4), which is the + same reference with a different name.""" + d = CLONE.sub("", dm(s)) b = base(d) return b if b in resigned else d @@ -186,12 +192,18 @@ def norm(s: str) -> str: ok = ok and base(r) in resigned print(f" removed: {removed or 'none'}") for k in removed: + if CLONE.search(k): + print(f" tolerated: {k} (a compiler clone of a library inline, no longer worth making)") + continue print(f" UNEXPECTED removal: {k}") ok = False print(f" added: {added or 'none'}") for k in added: if k.startswith("_GLOBAL__sub_I"): print(f" tolerated: {k} (static initialiser of the new TU)") + elif CLONE.search(k): + print(f" tolerated: {k} (a compiler clone of a library inline: the smaller TU " + f"specialised it for one caller; reference sets compare it as the original)") elif base(k) in published: # a static nobody read was eliminated by the compiler; published, it # exists (band 3b: write-only gnrmcFixThisCycle). Report it: dead state. diff --git a/src/navigation.cpp b/src/navigation.cpp new file mode 100644 index 0000000..5945620 --- /dev/null +++ b/src/navigation.cpp @@ -0,0 +1,268 @@ +// navigation.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "navigation.h" +#include "ui_state.h" +#include "screen_compass.h" +#include "screen_env.h" +#include "screen_telemetry.h" +#include "screen_geocache.h" +#include "screen_settings.h" +#include "oled_screens.h" +#include "settings.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "display.h" +#include "oled.h" +#include "logging.h" + +static void gearIconClickCb(lv_event_t* e); // registered by fcHeaderCreate, defined after it + +static lv_obj_t* fcListPickerActiveOverlay = NULL; +static lv_obj_t** const mainScreens[] = { &compassScr, &geocacheScr, &envScr, &telemetryScr }; + +// --- Header Bar: 30px cyan bar with title + gear icon --- +lv_obj_t* fcHeaderCreate(lv_obj_t* parent, const char* title) { + lv_obj_t* cont = lv_obj_create(parent); + lv_obj_remove_style_all(cont); + lv_obj_set_size(cont, SCREEN_W, 30); + lv_obj_set_pos(cont, 0, 0); + lv_obj_set_style_bg_color(cont, FC_COLOR_HEADER, 0); + lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0); + lv_obj_clear_flag(cont, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + + // Child [0]: title label — black on cyan + lv_obj_t* titleLbl = lv_label_create(cont); + lv_label_set_text(titleLbl, title); + lv_obj_set_style_text_color(titleLbl, FC_COLOR_BG, 0); + lv_obj_set_style_text_font(titleLbl, FC_FONT_SM, 0); + lv_obj_set_pos(titleLbl, 10, 7); + + // Child [1]: gear button — tappable settings icon + lv_obj_t* gearBtn = lv_button_create(cont); + lv_obj_remove_style_all(gearBtn); + lv_obj_set_size(gearBtn, 30, 30); + lv_obj_align(gearBtn, LV_ALIGN_RIGHT_MID, -5, 0); + lv_obj_set_style_bg_opa(gearBtn, LV_OPA_TRANSP, 0); + + lv_obj_t* gearLbl = lv_label_create(gearBtn); + lv_label_set_text(gearLbl, LV_SYMBOL_SETTINGS); + lv_obj_set_style_text_color(gearLbl, FC_COLOR_BG, 0); + lv_obj_set_style_text_font(gearLbl, FC_FONT_SM, 0); + lv_obj_center(gearLbl); + + // Gear icon click → Settings (#113) — callback defined in navigation section + lv_obj_add_event_cb(gearBtn, gearIconClickCb, LV_EVENT_CLICKED, NULL); + + // Child [2]: SD status indicator — hidden when healthy (#120) + lv_obj_t* sdLbl = lv_label_create(cont); + lv_label_set_text(sdLbl, LV_SYMBOL_SD_CARD); + lv_obj_set_style_text_font(sdLbl, FC_FONT_SM, 0); + lv_obj_set_style_text_color(sdLbl, lv_color_hex(0x808080), 0); // default gray + lv_obj_align(sdLbl, LV_ALIGN_RIGHT_MID, -38, 0); + // Starts visible — updateSDIndicators() sets color per state (#120) + + return cont; +} + +// Internal: list item click handler — select and close +static void fcListPickerItemCb(lv_event_t* e) { + lv_obj_t* itemBtn = (lv_obj_t*)lv_event_get_target(e); + int idx = (int)(intptr_t)lv_obj_get_user_data(itemBtn); + + // Navigate up: itemBtn -> list -> box -> overlay + lv_obj_t* list = lv_obj_get_parent(itemBtn); + lv_obj_t* box = lv_obj_get_parent(list); + lv_obj_t* overlay = lv_obj_get_parent(box); + + // Get caller stored in overlay user_data + lv_obj_t* caller = (lv_obj_t*)lv_obj_get_user_data(overlay); + + // Notify caller with selected index + if (caller) { + lv_obj_send_event(caller, LV_EVENT_VALUE_CHANGED, (void*)(intptr_t)idx); + } + + // Close: async-delete overlay to avoid use-after-free during event callback (#112) + fcListPickerActiveOverlay = NULL; // Clear tracker before delete (#119) + lv_obj_delete_async(overlay); +} + +lv_obj_t* fcListPickerOpen(const char* title, const char** items, + int count, int selectedIdx, lv_obj_t* caller) { + // Guard: destroy any existing overlay before creating a new one (#119) + if (fcListPickerActiveOverlay != NULL) { + logPrintln("[LVGL/MEM] Closing stale list picker overlay before opening new one"); + lv_obj_delete(fcListPickerActiveOverlay); + fcListPickerActiveOverlay = NULL; + } + + // Safety check: ensure enough LVGL heap for overlay (~count*2+4 objects) (#119) + lv_mem_monitor_t mon; + lv_mem_monitor(&mon); + logPrintf("[LVGL/MEM] ListPicker open '%s' (%d items): free=%lu frag=%d%%\n", + title, count, (unsigned long)mon.free_size, mon.frag_pct); + if (mon.free_size < 8192) { // 8KB minimum — raised from 4KB after OOM crash (#119) + logPrintf("[LVGL/MEM] ABORT: insufficient heap (%lu < 8192) for picker!\n", + (unsigned long)mon.free_size); + return NULL; + } + + // Full-screen semi-transparent overlay + lv_obj_t* overlay = lv_obj_create(lv_screen_active()); + if (!overlay) { logPrintln("[LVGL/MEM] ListPicker overlay alloc failed"); return NULL; } + lv_obj_remove_style_all(overlay); + lv_obj_set_size(overlay, SCREEN_W, SCREEN_H); + lv_obj_set_pos(overlay, 0, 0); + lv_obj_set_style_bg_color(overlay, FC_COLOR_BG, 0); + lv_obj_set_style_bg_opa(overlay, LV_OPA_50, 0); + lv_obj_clear_flag(overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_user_data(overlay, (void*)caller); + + // Modal container: 420x220, centered + lv_obj_t* box = lv_obj_create(overlay); + if (!box) { logPrintln("[LVGL/MEM] ListPicker box alloc failed"); lv_obj_delete(overlay); return NULL; } + lv_obj_remove_style_all(box); + lv_obj_set_size(box, 420, 220); + lv_obj_center(box); + lv_obj_set_style_bg_color(box, FC_COLOR_W_OVERLAY, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, FC_COLOR_DIM, 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_radius(box, 8, 0); + lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); + + // Title label + lv_obj_t* titleLbl = lv_label_create(box); + if (!titleLbl) { lv_obj_delete(overlay); return NULL; } + lv_label_set_text(titleLbl, title); + lv_obj_set_style_text_color(titleLbl, FC_COLOR_HEADER, 0); + lv_obj_set_style_text_font(titleLbl, FC_FONT_SM, 0); + lv_obj_set_pos(titleLbl, 10, 6); + + // Scrollable list area + lv_obj_t* listArea = lv_obj_create(box); + if (!listArea) { lv_obj_delete(overlay); return NULL; } + lv_obj_remove_style_all(listArea); + lv_obj_set_size(listArea, 410, 180); + lv_obj_set_pos(listArea, 5, 32); + lv_obj_set_style_bg_opa(listArea, LV_OPA_TRANSP, 0); + lv_obj_set_flex_flow(listArea, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(listArea, 4, 0); + + // List items + for (int i = 0; i < count; i++) { + lv_obj_t* itemBtn = lv_button_create(listArea); + if (!itemBtn) break; // OOM mid-loop — stop gracefully + lv_obj_set_size(itemBtn, 400, 32); + lv_obj_set_style_bg_color(itemBtn, + (i == selectedIdx) ? FC_COLOR_W_OK : FC_COLOR_W_OVERLAY, 0); + lv_obj_set_style_radius(itemBtn, 4, 0); + lv_obj_set_style_min_height(itemBtn, 32, 0); + lv_obj_set_user_data(itemBtn, (void*)(intptr_t)i); + lv_obj_add_event_cb(itemBtn, fcListPickerItemCb, LV_EVENT_CLICKED, NULL); + + lv_obj_t* itemLbl = lv_label_create(itemBtn); + if (!itemLbl) break; + lv_label_set_text(itemLbl, items[i]); + lv_obj_set_style_text_color(itemLbl, FC_COLOR_TEXT, 0); + lv_obj_set_style_text_font(itemLbl, FC_FONT_SM, 0); + lv_obj_align(itemLbl, LV_ALIGN_LEFT_MID, 10, 0); + } + + // Scroll to selected item + if (selectedIdx > 0 && selectedIdx < count) { + lv_obj_t* selBtn = lv_obj_get_child(listArea, selectedIdx); + if (selBtn) lv_obj_scroll_to_view(selBtn, LV_ANIM_OFF); + } + + fcListPickerActiveOverlay = overlay; + return overlay; +} + +void navigateScreen(int delta) { + int next = (currentScreen + delta + NUM_SCREENS) % NUM_SCREENS; + lv_scr_load_anim_t anim = (delta > 0) + ? LV_SCR_LOAD_ANIM_OVER_LEFT : LV_SCR_LOAD_ANIM_OVER_RIGHT; + lv_screen_load_anim(*mainScreens[next], anim, 80, 0, false); + currentScreen = next; + geocacheSubScreen = 0; +} + +void navigateToSettings() { + previousScreen = currentScreen; + currentScreen = SCREEN_SETTINGS; + settingsSubScreen = 0; + lv_screen_load_anim(settingsScr, LV_SCR_LOAD_ANIM_OVER_LEFT, 80, 0, false); + logPrintf("[NAV] → Settings (from screen %d)\n", previousScreen); +} + +void navigateFromSettings() { + saveSettings(); + settingsSubScreen = 0; + currentScreen = previousScreen; + lv_screen_load_anim(*mainScreens[currentScreen], LV_SCR_LOAD_ANIM_OVER_RIGHT, 80, 0, false); + logPrintf("[NAV] Settings → screen %d\n", currentScreen); +} + +// LVGL gesture callback — swipe left/right on main screens (#113) +void screenGestureCb(lv_event_t* e) { // published: every screen builder registers it (#265) + (void)e; + lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_active()); + if (dir == LV_DIR_LEFT) navigateScreen(1); + else if (dir == LV_DIR_RIGHT) navigateScreen(-1); +} + +// Gear icon click — navigate to Settings (#113) +static void gearIconClickCb(lv_event_t* e) { + (void)e; + if (currentScreen == SCREEN_SETTINGS) return; + navigateToSettings(); +} + +// Update SD card status indicator on all screen headers (#120) +void updateSDIndicators() { + SDIndicatorState state; + if (sdAvailable && sdHealth.consecutiveFailures == 0) { + state = SD_IND_OK; + } else if (sdAvailable) { + state = SD_IND_ERROR; // mounted but experiencing errors + } else { + state = SD_IND_MISSING; // never mounted or fully failed + } + fcHeaderSetSDStatus(compassHeader, state); + fcHeaderSetSDStatus(gcNavHeader, state); + fcHeaderSetSDStatus(envHeader, state); + fcHeaderSetSDStatus(telHeader, state); +} + +void updateDisplay() { + static unsigned long lastUpdate = 0; + + // Throttle: update data every 500ms (LVGL rendering runs independently) + if (millis() - lastUpdate < 500) return; + lastUpdate = millis(); + + // Update TFT display data (if not sleeping) + if (!tftSleeping) { + // Update SD card status indicator on all screen headers (#120) + updateSDIndicators(); + + // Update active screen data — LVGL handles rendering via lv_timer_handler() + switch (currentScreen) { + case SCREEN_COMPASS: updateCompassData(); fcNavBarSetActive(compassNavBar, currentScreen); break; + case SCREEN_GEOCACHE: updateGeocacheData(); break; + case SCREEN_ENV: updateEnvData(); break; + case SCREEN_TELEMETRY: updateTelemetryData(); break; + case SCREEN_SETTINGS: updateSettingsData(); break; + } + + // Track TFT update for health monitoring + lastTFTUpdate = millis(); + tftUpdateCount++; + } + + // Update OLED display (if available and not sleeping) + if (oledAvailable && !oledSleeping) { + updateOLED(); + } +} diff --git a/src/navigation.h b/src/navigation.h new file mode 100644 index 0000000..083af08 --- /dev/null +++ b/src/navigation.h @@ -0,0 +1,12 @@ +#pragma once +// navigation.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +lv_obj_t* fcHeaderCreate(lv_obj_t* parent, const char* title); +lv_obj_t* fcListPickerOpen(const char* title, const char** items, int count, int selectedIdx, lv_obj_t* caller); +void screenGestureCb(lv_event_t* e); // swipe left/right on a main screen +void navigateScreen(int delta); +void navigateToSettings(); +void navigateFromSettings(); +void updateSDIndicators(); +void updateDisplay(); diff --git a/src/screen_compass.cpp b/src/screen_compass.cpp new file mode 100644 index 0000000..93ccbc8 --- /dev/null +++ b/src/screen_compass.cpp @@ -0,0 +1,420 @@ +// screen_compass.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "screen_compass.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "navigation.h" +#include "ui_state.h" +#include "gps.h" +#include "imu.h" +#include "env.h" +#include "weather.h" +#include "settings.h" +#include "geo.h" +#include "logging.h" + +lv_obj_t* compassScr = NULL; // Root container (full screen) +lv_obj_t* compassHeader = NULL; // fcHeader widget +lv_obj_t* compassNavBar = NULL; // fcNavBar widget +static lv_obj_t* compassRoseObj = NULL; // Custom draw rose area +static lv_obj_t* compassLblHdg = NULL; // "204° SW" +static lv_obj_t* compassLblLat = NULL; // "Lat 39.3525N" +static lv_obj_t* compassLblLon = NULL; // "Lon 84.3825W" +static lv_obj_t* compassLblAlt = NULL; // "Alt 820 ft" +static lv_obj_t* compassLblSpd = NULL; // "Spd 2.3 mph" +static lv_obj_t* compassLblTemp = NULL; // "Temp 72.5°F" +static lv_obj_t* compassLblFcst = NULL; // "↑ Fair" +static lv_obj_t* compassLblGps = NULL; // "GPS OK Sat:8" +static lv_obj_t* compassLblTime = NULL; // "3:42:15 PM" +static lv_obj_t* compassLblN = NULL; +static lv_obj_t* compassLblE = NULL; +static lv_obj_t* compassLblS = NULL; +static lv_obj_t* compassLblW = NULL; +static float compassLastHeading = -999.0f; + +static void compassRoseDrawCb(lv_event_t* e); // registered by the builder, defined after it + +void buildCompassScreen() { + // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() + compassScr = lv_obj_create(NULL); + lv_obj_set_size(compassScr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(compassScr, FC_COLOR_BG, 0); + lv_obj_set_style_border_width(compassScr, 0, 0); + lv_obj_set_style_radius(compassScr, 0, 0); + lv_obj_set_style_pad_all(compassScr, 0, 0); + lv_obj_clear_flag(compassScr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(compassScr, screenGestureCb, LV_EVENT_GESTURE, NULL); + + // Header bar (reuse #108 widget) + compassHeader = fcHeaderCreate(compassScr, "COMPASS"); + + // Vertical separator line between panels + lv_obj_t* sep = lv_obj_create(compassScr); + lv_obj_remove_style_all(sep); + lv_obj_set_size(sep, 1, 256); + lv_obj_set_pos(sep, 178, 34); + lv_obj_set_style_bg_color(sep, lv_color_hex(0x212121), 0); + lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, 0); + + // === Left Panel Labels === + + // Heading + cardinal (large green text) + compassLblHdg = lv_label_create(compassScr); + lv_obj_set_pos(compassLblHdg, 8, 34); + lv_obj_set_style_text_font(compassLblHdg, FC_FONT_XXL, 0); + lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblHdg, "--"); + + // Latitude + compassLblLat = lv_label_create(compassScr); + lv_obj_set_pos(compassLblLat, 8, 72); + lv_obj_set_style_text_font(compassLblLat, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblLat, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblLat, "Lat --"); + + // Longitude + compassLblLon = lv_label_create(compassScr); + lv_obj_set_pos(compassLblLon, 8, 90); + lv_obj_set_style_text_font(compassLblLon, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblLon, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblLon, "Lon --"); + + // Altitude + compassLblAlt = lv_label_create(compassScr); + lv_obj_set_pos(compassLblAlt, 8, 112); + lv_obj_set_style_text_font(compassLblAlt, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblAlt, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblAlt, "Alt --"); + + // Speed + compassLblSpd = lv_label_create(compassScr); + lv_obj_set_pos(compassLblSpd, 8, 132); + lv_obj_set_style_text_font(compassLblSpd, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblSpd, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblSpd, "Spd --"); + + // Temperature + compassLblTemp = lv_label_create(compassScr); + lv_obj_set_pos(compassLblTemp, 8, 152); + lv_obj_set_style_text_font(compassLblTemp, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblTemp, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblTemp, "Temp --"); + + // Forecast + compassLblFcst = lv_label_create(compassScr); + lv_obj_set_pos(compassLblFcst, 8, 172); + lv_obj_set_style_text_font(compassLblFcst, FC_FONT_SM, 0); + lv_obj_set_style_text_color(compassLblFcst, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblFcst, "Fcst --"); + + // GPS status + compassLblGps = lv_label_create(compassScr); + lv_obj_set_pos(compassLblGps, 8, 200); + lv_obj_set_style_text_font(compassLblGps, FC_FONT_XS, 0); + lv_obj_set_style_text_color(compassLblGps, FC_COLOR_VALUE, 0); + lv_label_set_text(compassLblGps, "GPS --"); + + // Time (bottom of left panel) + compassLblTime = lv_label_create(compassScr); + lv_obj_set_pos(compassLblTime, 8, 268); + lv_obj_set_style_text_font(compassLblTime, FC_FONT_LG, 0); + lv_obj_set_style_text_color(compassLblTime, FC_COLOR_TEXT, 0); + lv_label_set_text(compassLblTime, "--:--"); + + // === Right Panel: Compass Rose (custom draw) === + compassRoseObj = lv_obj_create(compassScr); + lv_obj_remove_style_all(compassRoseObj); + lv_obj_set_size(compassRoseObj, 298, 260); + lv_obj_set_pos(compassRoseObj, 182, 30); + lv_obj_clear_flag(compassRoseObj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(compassRoseObj, compassRoseDrawCb, LV_EVENT_DRAW_MAIN, NULL); + + // Lubber line — fixed orange triangle at top of rose (does not rotate) + // Uses a small filled rectangle as approximation; future polish can use draw callback + lv_obj_t* lubber = lv_obj_create(compassScr); + lv_obj_remove_style_all(lubber); + lv_obj_set_size(lubber, 14, 10); + lv_obj_set_pos(lubber, 324, 40); // Just above outer ring (top at y=52), below header (y=30) + lv_obj_set_style_bg_color(lubber, FC_COLOR_WARN, 0); + lv_obj_set_style_bg_opa(lubber, LV_OPA_COVER, 0); + lv_obj_set_style_radius(lubber, 2, 0); + + // Cardinal direction labels (N/E/S/W — positioned dynamically in updateCompassData) + const char* cardinals[] = {"N", "E", "S", "W"}; + lv_obj_t** cardLbls[] = {&compassLblN, &compassLblE, &compassLblS, &compassLblW}; + lv_color_t cardColors[] = { + lv_color_hex(0x00FFFF), // N cyan (matches north needle) + lv_color_hex(0xFFFFFF), // E white + lv_color_hex(0xFF0000), // S red (matches south needle) + lv_color_hex(0xFFFFFF), // W white + }; + for (int i = 0; i < 4; i++) { + *cardLbls[i] = lv_label_create(compassScr); + lv_label_set_text(*cardLbls[i], cardinals[i]); + lv_obj_set_style_text_font(*cardLbls[i], FC_FONT_SM, 0); + lv_obj_set_style_text_color(*cardLbls[i], cardColors[i], 0); + lv_obj_set_pos(*cardLbls[i], -50, -50); // Off-screen until first heading update + } + + // NavBar at bottom + compassNavBar = fcNavBarCreate(compassScr, NUM_SCREENS, SCREEN_COMPASS); + + + logPrintln("[LVGL] Compass screen built (#109)"); +} + +// Compass rose custom draw callback — anti-aliased via LVGL primitives (#109) +static void compassRoseDrawCb(lv_event_t* e) { + lv_obj_t* obj = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + + // Rose geometry — center of the 298x260 container + lv_area_t coords; + lv_obj_get_coords(obj, &coords); + int32_t objW = lv_obj_get_width(obj); + int32_t objH = lv_obj_get_height(obj); + int32_t cx = coords.x1 + objW / 2; + int32_t cy = coords.y1 + objH / 2; + int32_t radius = 108; + + float heading = compassLastHeading; + if (heading < 0) heading = 0; + float rotDeg = -heading; + + // --- Outer ring (anti-aliased arc) --- + lv_draw_arc_dsc_t arcDsc; + lv_draw_arc_dsc_init(&arcDsc); + arcDsc.center.x = cx; + arcDsc.center.y = cy; + arcDsc.radius = radius; + arcDsc.width = 2; + arcDsc.start_angle = 0; + arcDsc.end_angle = 360; + arcDsc.color = lv_color_hex(0x808080); + arcDsc.opa = LV_OPA_COVER; + lv_draw_arc(layer, &arcDsc); + + // --- Tick marks (12 ticks every 30°) --- + int tickCardLen = radius / 10; + int tickInterLen = radius / 20; + + for (int i = 0; i < 12; i++) { + float tickAngle = (i * 30.0f + rotDeg - 90.0f) * (float)M_PI / 180.0f; + int tickLen = (i % 3 == 0) ? tickCardLen : tickInterLen; + + lv_draw_line_dsc_t lineDsc; + lv_draw_line_dsc_init(&lineDsc); + lineDsc.p1.x = cx + (int32_t)(cosf(tickAngle) * (radius - tickLen)); + lineDsc.p1.y = cy + (int32_t)(sinf(tickAngle) * (radius - tickLen)); + lineDsc.p2.x = cx + (int32_t)(cosf(tickAngle) * radius); + lineDsc.p2.y = cy + (int32_t)(sinf(tickAngle) * radius); + lineDsc.width = (i % 3 == 0) ? 2 : 1; + lineDsc.color = lv_color_hex(0x808080); + lineDsc.opa = LV_OPA_COVER; + lv_draw_line(layer, &lineDsc); + } + + // --- 8 Diamond needles --- + struct { + float angle; + int length; + int halfWidth; + lv_color_t color; + lv_color_t tailColor; + } needles[] = { + { 0, radius*93/100, radius*10/100, lv_color_hex(0x00FFFF), lv_color_hex(0x212121)}, // N cyan + { 45, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // NE gray + { 90, radius*93/100, radius*10/100, lv_color_hex(0xFFFFFF), lv_color_hex(0x212121)}, // E white + {135, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // SE gray + {180, radius*93/100, radius*10/100, lv_color_hex(0xFF0000), lv_color_hex(0x212121)}, // S red + {225, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // SW gray + {270, radius*93/100, radius*10/100, lv_color_hex(0xFFFFFF), lv_color_hex(0x212121)}, // W white + {315, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // NW gray + }; + + for (int i = 0; i < 8; i++) { + float tipRad = (needles[i].angle + rotDeg - 90.0f) * (float)M_PI / 180.0f; + float perpRad = tipRad + (float)M_PI / 2.0f; + + int32_t tipX = cx + (int32_t)(cosf(tipRad) * needles[i].length); + int32_t tipY = cy + (int32_t)(sinf(tipRad) * needles[i].length); + + int32_t sX1 = cx + (int32_t)(cosf(perpRad) * needles[i].halfWidth); + int32_t sY1 = cy + (int32_t)(sinf(perpRad) * needles[i].halfWidth); + int32_t sX2 = cx - (int32_t)(cosf(perpRad) * needles[i].halfWidth); + int32_t sY2 = cy - (int32_t)(sinf(perpRad) * needles[i].halfWidth); + + float tailRad = tipRad + (float)M_PI; + int tailLen = needles[i].length / 3; + int32_t tailX = cx + (int32_t)(cosf(tailRad) * tailLen); + int32_t tailY = cy + (int32_t)(sinf(tailRad) * tailLen); + + // Tip triangle (front half of diamond) + lv_draw_triangle_dsc_t triDsc; + lv_draw_triangle_dsc_init(&triDsc); + triDsc.p[0].x = tipX; triDsc.p[0].y = tipY; + triDsc.p[1].x = sX1; triDsc.p[1].y = sY1; + triDsc.p[2].x = sX2; triDsc.p[2].y = sY2; + triDsc.color = needles[i].color; + triDsc.opa = LV_OPA_COVER; + lv_draw_triangle(layer, &triDsc); + + // Tail triangle (back half of diamond) + triDsc.p[0].x = tailX; triDsc.p[0].y = tailY; + triDsc.color = needles[i].tailColor; + lv_draw_triangle(layer, &triDsc); + } + + // --- Center hub (filled circle via thick arc) --- + int hubR = max(5, (int)(radius / 15)); + lv_draw_arc_dsc_t hubDsc; + lv_draw_arc_dsc_init(&hubDsc); + hubDsc.center.x = cx; + hubDsc.center.y = cy; + hubDsc.radius = hubR; + hubDsc.width = hubR; + hubDsc.start_angle = 0; + hubDsc.end_angle = 360; + hubDsc.color = lv_color_hex(0xFFFFFF); + hubDsc.opa = LV_OPA_COVER; + lv_draw_arc(layer, &hubDsc); + + // Hub outline + lv_draw_arc_dsc_t hubOutDsc; + lv_draw_arc_dsc_init(&hubOutDsc); + hubOutDsc.center.x = cx; + hubOutDsc.center.y = cy; + hubOutDsc.radius = hubR; + hubOutDsc.width = 1; + hubOutDsc.start_angle = 0; + hubOutDsc.end_angle = 360; + hubOutDsc.color = lv_color_hex(0x808080); + hubOutDsc.opa = LV_OPA_COVER; + lv_draw_arc(layer, &hubOutDsc); +} + +// Update left panel labels from sensor data (called from updateDisplay at 2Hz) +void updateCompassData() { + char buf[64]; + + // Section 1: Heading + cardinal + if (imuAvailable && magAvailable) { + const char* card = getCardinal(imuData.heading); + lv_label_set_text_fmt(compassLblHdg, "%.0f\xC2\xB0 %s", imuData.heading, card); + lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_VALUE, 0); + } else { + lv_label_set_text(compassLblHdg, "No IMU"); + lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_ERROR, 0); + } + + // Section 2: GPS coordinates + if (gpsData.valid) { + lv_label_set_text_fmt(compassLblLat, "Lat %.4f%c", + fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S'); + lv_label_set_text_fmt(compassLblLon, "Lon %.4f%c", + fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W'); + lv_obj_set_style_text_color(compassLblLat, FC_COLOR_VALUE, 0); + lv_obj_set_style_text_color(compassLblLon, FC_COLOR_VALUE, 0); + } else if (gpsData.receiving) { + lv_label_set_text(compassLblLat, "GPS Acquiring..."); + lv_label_set_text_fmt(compassLblLon, "Sats: %d", gpsData.satellites); + lv_obj_set_style_text_color(compassLblLat, FC_COLOR_WARN, 0); + lv_obj_set_style_text_color(compassLblLon, FC_COLOR_WARN, 0); + } else { + lv_label_set_text(compassLblLat, "No GPS"); + lv_label_set_text(compassLblLon, ""); + lv_obj_set_style_text_color(compassLblLat, FC_COLOR_ERROR, 0); + } + + // Section 3: Altitude + if (gpsData.valid) { + float alt = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; + lv_label_set_text_fmt(compassLblAlt, "Alt %.0f %s", + alt, useMetricUnits ? "m" : "ft"); + } else { + lv_label_set_text(compassLblAlt, "Alt --"); + } + + // Section 4: Speed + if (gpsData.valid) { + float speed = gpsData.speedKnots * (useMetricUnits ? 1.852 : 1.15078); + lv_label_set_text_fmt(compassLblSpd, "Spd %.1f %s", + speed, useMetricUnits ? "km/h" : "mph"); + } else { + lv_label_set_text_fmt(compassLblSpd, "Spd -- %s", + useMetricUnits ? "km/h" : "mph"); + } + + // Section 5: Temperature + bool hasTempSensor = shtAvailable || bmeAvailable; + if (hasTempSensor) { + float tempC = shtAvailable ? shtData.temperature : envData.temperature; + float tempDisplay = useFahrenheit ? tempC * 9.0 / 5.0 + 32.0 : tempC; + lv_label_set_text_fmt(compassLblTemp, "Temp %.1f\xC2\xB0%c", + tempDisplay, useFahrenheit ? 'F' : 'C'); + } else { + lv_label_set_text(compassLblTemp, "Temp --"); + } + + // Section 6: Forecast with color coding + const char* fc = weatherTrend.forecast; + lv_color_t fcstColor = FC_COLOR_VALUE; + if (strstr(fc, "Storm")) fcstColor = FC_COLOR_ERROR; + else if (strstr(fc, "Rain") || strstr(fc, "Snow") || + strstr(fc, "Unsettled") || strstr(fc, "Precip")) fcstColor = FC_COLOR_WARN; + else if (strcmp(fc, "Init") == 0 || strcmp(fc, "Learning") == 0 || + strcmp(fc, "Traveled") == 0) fcstColor = FC_COLOR_DIM; + lv_obj_set_style_text_color(compassLblFcst, fcstColor, 0); + lv_label_set_text_fmt(compassLblFcst, "Fcst %s %s", getTrendArrow(), fc); + + // Section 7: GPS status + if (gpsData.valid) { + lv_label_set_text_fmt(compassLblGps, "GPS OK Sat:%d HDOP:%.1f", + gpsData.satellites, gpsData.hdop); + lv_obj_set_style_text_color(compassLblGps, FC_COLOR_VALUE, 0); + } else if (gpsData.receiving) { + lv_label_set_text_fmt(compassLblGps, "GPS Acquiring Sat:%d", + gpsData.satellites); + lv_obj_set_style_text_color(compassLblGps, FC_COLOR_WARN, 0); + } else { + lv_label_set_text(compassLblGps, "No GPS"); + lv_obj_set_style_text_color(compassLblGps, FC_COLOR_ERROR, 0); + } + + // Section 8: Time + char timeBuf[16]; + struct tm timeinfo; + if (getLocalTime(&timeinfo, 0)) { + formatTimeStr(timeBuf, timeinfo.tm_hour, timeinfo.tm_min, + timeinfo.tm_sec, false); + } else { + strcpy(timeBuf, "--:--"); + } + lv_label_set_text(compassLblTime, timeBuf); + + // Section 9: Invalidate compass rose if heading changed >2° + if (imuAvailable && magAvailable) { + float diff = fabs(imuData.heading - compassLastHeading); + if (diff > 180) diff = 360 - diff; // Wrap-around + if (diff >= 2.0f) { + compassLastHeading = imuData.heading; + lv_obj_invalidate(compassRoseObj); + + // Section 10: Reposition cardinal direction labels around rose + if (compassLblN) { + int32_t roseCx = 331; // Center of rose (182 + 298/2) + int32_t roseCy = 160; // Center of rose (30 + 260/2) + int32_t labelR = 120; // Just outside outer ring (radius=108 + padding) + float cardAngles[] = {0, 90, 180, 270}; + lv_obj_t* cardObjs[] = {compassLblN, compassLblE, compassLblS, compassLblW}; + float rot = -compassLastHeading; + + for (int ci = 0; ci < 4; ci++) { + float rad = (cardAngles[ci] + rot - 90.0f) * (float)M_PI / 180.0f; + int32_t lx = roseCx + (int32_t)(cosf(rad) * labelR) - 5; // ~half char width + int32_t ly = roseCy + (int32_t)(sinf(rad) * labelR) - 8; // ~half char height + lv_obj_set_pos(cardObjs[ci], lx, ly); + } + } + } + } +} diff --git a/src/screen_compass.h b/src/screen_compass.h new file mode 100644 index 0000000..0c1ed49 --- /dev/null +++ b/src/screen_compass.h @@ -0,0 +1,10 @@ +#pragma once +// screen_compass.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +extern lv_obj_t* compassScr; +extern lv_obj_t* compassHeader; +extern lv_obj_t* compassNavBar; + +void buildCompassScreen(); +void updateCompassData(); diff --git a/src/screen_env.cpp b/src/screen_env.cpp new file mode 100644 index 0000000..a31f33d --- /dev/null +++ b/src/screen_env.cpp @@ -0,0 +1,198 @@ +// screen_env.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "screen_env.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "navigation.h" +#include "ui_state.h" +#include "env.h" +#include "weather.h" +#include "settings.h" +#include "logging.h" + +lv_obj_t* envScr = NULL; +lv_obj_t* envHeader = NULL; +static lv_obj_t* envNavBar = NULL; +static lv_obj_t* envLblTempLabel = NULL; +static lv_obj_t* envLblTempValue = NULL; +static lv_obj_t* envLblHumidLabel = NULL; +static lv_obj_t* envLblHumidValue = NULL; +static lv_obj_t* envLblIaqLabel = NULL; +static lv_obj_t* envLblIaqValue = NULL; +static lv_obj_t* envLblCo2Label = NULL; +static lv_obj_t* envLblCo2Value = NULL; +static lv_obj_t* envLblPressLabel = NULL; +static lv_obj_t* envLblPressValue = NULL; +static lv_obj_t* envLblFcstLabel = NULL; +static lv_obj_t* envLblFcstValue = NULL; +static lv_obj_t* envLblNoSensors = NULL; + +void buildEnvScreen() { + // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() + envScr = lv_obj_create(NULL); + lv_obj_set_size(envScr, 480, 320); + lv_obj_set_style_bg_color(envScr, FC_COLOR_BG, 0); + lv_obj_set_style_border_width(envScr, 0, 0); + lv_obj_set_style_radius(envScr, 0, 0); + lv_obj_set_style_pad_all(envScr, 0, 0); + lv_obj_clear_flag(envScr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(envScr, screenGestureCb, LV_EVENT_GESTURE, NULL); + + // Header + envHeader = fcHeaderCreate(envScr, "ENVIRONMENT"); + + // Helper lambda for label pairs + int labelX = 10; + int valueX = 80; + int y = 42; + int lineH = 28; + + auto makeLabelPair = [&](lv_obj_t** lblOut, lv_obj_t** valOut, + const char* labelText, int row) { + int rowY = y + row * lineH; + + *lblOut = lv_label_create(envScr); + lv_label_set_text(*lblOut, labelText); + lv_obj_set_pos(*lblOut, labelX, rowY); + lv_obj_set_style_text_font(*lblOut, FC_FONT_SM, 0); + lv_obj_set_style_text_color(*lblOut, FC_COLOR_DIM, 0); + + *valOut = lv_label_create(envScr); + lv_label_set_text(*valOut, "---"); + lv_obj_set_pos(*valOut, valueX, rowY); + lv_obj_set_style_text_font(*valOut, FC_FONT_MD, 0); + lv_obj_set_style_text_color(*valOut, FC_COLOR_VALUE, 0); + }; + + makeLabelPair(&envLblTempLabel, &envLblTempValue, "Temp:", 0); + makeLabelPair(&envLblHumidLabel, &envLblHumidValue, "Humid:", 1); + makeLabelPair(&envLblIaqLabel, &envLblIaqValue, "IAQ:", 2); + makeLabelPair(&envLblCo2Label, &envLblCo2Value, "CO2:", 3); + makeLabelPair(&envLblPressLabel, &envLblPressValue, "Press:", 4); + makeLabelPair(&envLblFcstLabel, &envLblFcstValue, "Fcst:", 5); + + // Error label (hidden unless no sensors at all) + envLblNoSensors = lv_label_create(envScr); + lv_label_set_text(envLblNoSensors, "No env sensors"); + lv_obj_set_style_text_font(envLblNoSensors, FC_FONT_LG, 0); + lv_obj_set_style_text_color(envLblNoSensors, FC_COLOR_ERROR, 0); + lv_obj_center(envLblNoSensors); + lv_obj_add_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); + + // NavBar + envNavBar = fcNavBarCreate(envScr, NUM_SCREENS, SCREEN_ENV); +} + +void updateEnvData() { + if (!envScr) return; + char buf[80]; + + fcNavBarSetActive(envNavBar, currentScreen); + + if (!bmeAvailable && !shtAvailable) { + // No sensors at all — show error, hide all rows + lv_obj_clear_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblTempLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblTempValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblHumidLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblHumidValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); + return; + } + + // Sensors available — hide error, show temp+humid rows + lv_obj_add_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblTempLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblTempValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblHumidLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblHumidValue, LV_OBJ_FLAG_HIDDEN); + + float tempC = shtAvailable ? shtData.temperature : envData.temperature; + float tempF = tempC * 9.0 / 5.0 + 32.0; + const char* tempSrc = shtAvailable ? "SHT" : "BME"; + + // Temp value (respects useFahrenheit) — UTF-8 degree sign \xC2\xB0 + if (useFahrenheit) + snprintf(buf, sizeof(buf), "%.1f\xC2\xB0""F (%.1fC) %s", tempF, tempC, tempSrc); + else + snprintf(buf, sizeof(buf), "%.1f\xC2\xB0""C (%.1fF) %s", tempC, tempF, tempSrc); + lv_label_set_text(envLblTempValue, buf); + + // Humidity + float humid = shtAvailable ? shtData.humidity : envData.humidity; + snprintf(buf, sizeof(buf), "%.1f%% %s", humid, tempSrc); + lv_label_set_text(envLblHumidValue, buf); + + if (bmeAvailable) { + // Show BME-only rows + lv_obj_clear_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); + + // IAQ with quality word and color coding (#31) + if (envData.iaqAccuracy == 0) + snprintf(buf, sizeof(buf), "-- [INIT]"); + else + snprintf(buf, sizeof(buf), "%.0f %s [%s]", envData.iaq, getIaqQualityText(envData.iaq), getIaqAccuracyText(envData.iaqAccuracy)); + lv_label_set_text(envLblIaqValue, buf); + if (envData.iaq > 200) + lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_ERROR, 0); + else if (envData.iaq > 100) + lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_WARN, 0); + else + lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_VALUE, 0); + + // CO2 with color coding + snprintf(buf, sizeof(buf), "%.0f ppm", envData.co2Equivalent); + lv_label_set_text(envLblCo2Value, buf); + if (envData.co2Equivalent > 2000) + lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_ERROR, 0); + else if (envData.co2Equivalent > 1000) + lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_WARN, 0); + else + lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_VALUE, 0); + + // Pressure + snprintf(buf, sizeof(buf), "%.1f hPa (%.2f\")", envData.pressure, hPaToInHg(envData.pressure)); + lv_label_set_text(envLblPressValue, buf); + + // Forecast with color coding + snprintf(buf, sizeof(buf), "%s %s", getTrendArrow(), weatherTrend.forecast); + lv_label_set_text(envLblFcstValue, buf); + if (strstr(weatherTrend.forecast, "Storm")) + lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_ERROR, 0); + else if (strstr(weatherTrend.forecast, "Rain") || strstr(weatherTrend.forecast, "Snow")) + lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_WARN, 0); + else + lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_VALUE, 0); + + } else { + // No BME — show N/A for BME-only rows + lv_obj_clear_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(envLblIaqValue, "N/A (no BME688)"); + lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_DIM, 0); + + lv_obj_clear_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(envLblPressValue, "N/A (no BME688)"); + lv_obj_set_style_text_color(envLblPressValue, FC_COLOR_DIM, 0); + + // Hide CO2 and Forecast when no BME + lv_obj_add_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); + } +} diff --git a/src/screen_env.h b/src/screen_env.h new file mode 100644 index 0000000..5b180b4 --- /dev/null +++ b/src/screen_env.h @@ -0,0 +1,9 @@ +#pragma once +// screen_env.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +extern lv_obj_t* envScr; +extern lv_obj_t* envHeader; + +void buildEnvScreen(); +void updateEnvData(); diff --git a/src/screen_geocache.cpp b/src/screen_geocache.cpp new file mode 100644 index 0000000..ce5810a --- /dev/null +++ b/src/screen_geocache.cpp @@ -0,0 +1,934 @@ +// screen_geocache.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "screen_geocache.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "navigation.h" +#include "ui_state.h" +#include "gps.h" +#include "imu.h" +#include "geocache.h" +#include "settings.h" +#include "geo.h" +#include "logging.h" + +lv_obj_t* geocacheScr = NULL; // Root container +static lv_obj_t* geocacheNavCtr = NULL; // Nav sub-screen container +static lv_obj_t* geocacheListCtr = NULL; // List sub-screen container +static lv_obj_t* geocacheDetailsCtr = NULL; // Details sub-screen container +lv_obj_t* gcNavHeader = NULL; +static lv_obj_t* gcNavNavBar = NULL; +static lv_obj_t* gcNavLblName = NULL; +static lv_obj_t* gcNavLblDist = NULL; +static lv_obj_t* gcNavLblDT = NULL; +static lv_obj_t* gcNavLblBearing = NULL; +static lv_obj_t* gcNavGraphicObj = NULL; // Custom draw area +static lv_obj_t* gcNavLblAccuracy = NULL; +static lv_obj_t* gcNavLblHint = NULL; +static lv_obj_t* gcListNavBar = NULL; +static lv_obj_t* gcListLblCount = NULL; +static lv_obj_t* gcListScrollCtr = NULL; +static lv_obj_t* gcListLblHints = NULL; +static lv_obj_t* gcListRows[MAX_CACHES] = {}; +static lv_obj_t* gcListRowLabel[MAX_CACHES] = {}; // Single formatted label per row +static lv_obj_t* gcDetNavBar = NULL; +static lv_obj_t* gcDetLblCount = NULL; +static lv_obj_t* gcDetLblName = NULL; +static lv_obj_t* gcDetLblGC = NULL; +static lv_obj_t* gcDetLblCoords = NULL; +static lv_obj_t* gcDetLblDT = NULL; +static lv_obj_t* gcDetLblDist = NULL; +static lv_obj_t* gcDetLblHintLabel = NULL; +static lv_obj_t* gcDetLblHint = NULL; +static lv_obj_t* gcDetLblFound = NULL; +static lv_obj_t* gcDetLblHints = NULL; +static lv_obj_t* geocacheFilterCtr = NULL; +static lv_obj_t* gcFiltLblFound = NULL; +static lv_obj_t* gcFiltLblSort = NULL; +static lv_obj_t* gcFiltLblDRange = NULL; +static lv_obj_t* gcFiltLblTRange = NULL; +static lv_obj_t* gcFiltLblDist = NULL; +static lv_obj_t* gcFiltLblActive = NULL; // Active filter indicator on list screen +static float gcNavLastBearing = -999; +static float gcNavLastHeading = -999; +static bool gcNavLastInZone = false; +static int32_t gcNavPulseRadius = 0; // For search zone animation +static const float gcDTPresets[][2] = { + {1.0f, 5.0f}, {1.0f, 2.0f}, {1.0f, 3.0f}, {2.0f, 4.0f}, {3.0f, 5.0f} +}; +static int gcDPresetIdx = 0; +static int gcTPresetIdx = 0; +static const float gcDistPresets[] = {0, 1.60934f, 8.04672f, 16.0934f, 40.2336f}; + +// Moved by hand (#265): the relocation graph could not see these -- the two +// headers are written and never read, the counts fold into constants, the +// colour helper inlines -- so nm never lists them and the harness was not told. +static lv_obj_t* gcListHeader = NULL; // List sub-screen header +static lv_obj_t* gcDetHeader = NULL; // Details sub-screen header +static const int GC_DT_PRESET_COUNT = 5; // D/T range presets: {min, max} pairs, tap cycles +static const int GC_DIST_PRESET_COUNT = 5; // Distance presets: 0 (all), 1.6km, 8km, 16km, 40km + +static lv_color_t getLvglAccuracyColor(float accuracyM) { + if (accuracyM < 10.0f) return FC_COLOR_VALUE; // Green — excellent + if (accuracyM < 25.0f) return FC_COLOR_WARN; // Orange — good + return FC_COLOR_ERROR; // Red — poor +} + +// The static callbacks, declared before the builder that registers them +// (src.ino declared the filter ones at file scope; the harness now does this) +static void geocacheNavDrawCb(lv_event_t* e); +static void gcNavListBtnCb(lv_event_t* e); +static void gcListRowTapCb(lv_event_t* e); +static void gcFilterBtnCb(lv_event_t* e); +static void gcFilterBackCb(lv_event_t* e); +static void gcFilterFoundCb(lv_event_t* e); +static void gcFilterSortCb(lv_event_t* e); +static void gcFilterDMinUpCb(lv_event_t* e); +static void gcFilterTMinUpCb(lv_event_t* e); +static void gcFilterDistCb(lv_event_t* e); +static void gcFilterResetCb(lv_event_t* e); + +static void geocacheNavDrawCb(lv_event_t* e) { + lv_obj_t* obj = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + + lv_area_t coords; + lv_obj_get_coords(obj, &coords); + int32_t cx = (coords.x1 + coords.x2) / 2; + int32_t cy = (coords.y1 + coords.y2) / 2; + + // Need valid cache and GPS data to draw + if (cacheListCount == 0 || !cacheList[selectedCacheIndex].valid) return; + if (!gpsData.valid) return; + + GeocacheEntry& cache = cacheList[selectedCacheIndex]; + float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + float distM = distKm * 1000.0f; + float bearing = calcBearing(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + float accuracyM = getGpsAccuracyMeters(); + bool inSearchZone = (distM < accuracyM); + + if (inSearchZone) { + // === Search Zone Circle (pulsing) === + float ratio = (accuracyM > 0) ? (distM / accuracyM) : 0; + int32_t baseR = 20 + (int32_t)((1.0f - ratio) * 40.0f); // 20-60px + int32_t r = baseR + gcNavPulseRadius; + + // Filled orange circle + lv_draw_arc_dsc_t arcDsc; + lv_draw_arc_dsc_init(&arcDsc); + arcDsc.center.x = cx; + arcDsc.center.y = cy; + arcDsc.radius = r; + arcDsc.start_angle = 0; + arcDsc.end_angle = 360; + arcDsc.color = FC_COLOR_WARN; + arcDsc.opa = LV_OPA_COVER; + arcDsc.width = r; // Filled + lv_draw_arc(layer, &arcDsc); + + // White outline + lv_draw_arc_dsc_t outDsc; + lv_draw_arc_dsc_init(&outDsc); + outDsc.center.x = cx; + outDsc.center.y = cy; + outDsc.radius = r + 2; + outDsc.start_angle = 0; + outDsc.end_angle = 360; + outDsc.color = FC_COLOR_TEXT; + outDsc.opa = LV_OPA_COVER; + outDsc.width = 2; + lv_draw_arc(layer, &outDsc); + + // Center dot + lv_draw_arc_dsc_t dotDsc; + lv_draw_arc_dsc_init(&dotDsc); + dotDsc.center.x = cx; + dotDsc.center.y = cy; + dotDsc.radius = 4; + dotDsc.start_angle = 0; + dotDsc.end_angle = 360; + dotDsc.color = FC_COLOR_TEXT; + dotDsc.opa = LV_OPA_COVER; + dotDsc.width = 4; + lv_draw_arc(layer, &dotDsc); + } else { + // === Direction Arrow (same math as legacy drawNavTriangle) === + float triangleAngle = bearing - imuData.heading; + if (triangleAngle < 0) triangleAngle += 360; + if (triangleAngle >= 360) triangleAngle -= 360; + + int size = 50; + float rad = (triangleAngle - 90.0f) * (float)M_PI / 180.0f; + + // Tip point + int32_t tipX = cx + (int32_t)(cosf(rad) * size); + int32_t tipY = cy + (int32_t)(sinf(rad) * size); + + // Rear corners (±140° from tip direction) + float rear1Rad = rad + 140.0f * (float)M_PI / 180.0f; + float rear2Rad = rad - 140.0f * (float)M_PI / 180.0f; + int32_t rear1X = cx + (int32_t)(cosf(rear1Rad) * size * 0.7f); + int32_t rear1Y = cy + (int32_t)(sinf(rear1Rad) * size * 0.7f); + int32_t rear2X = cx + (int32_t)(cosf(rear2Rad) * size * 0.7f); + int32_t rear2Y = cy + (int32_t)(sinf(rear2Rad) * size * 0.7f); + + // Rear center notch + float rearCRad = rad + 180.0f * (float)M_PI / 180.0f; + int32_t rearCX = cx + (int32_t)(cosf(rearCRad) * size * 0.3f); + int32_t rearCY = cy + (int32_t)(sinf(rearCRad) * size * 0.3f); + + lv_color_t arrowColor = FC_COLOR_HEADER; // Cyan + + // Triangle 1: tip → rear1 → rearCenter + lv_draw_triangle_dsc_t tri1; + lv_draw_triangle_dsc_init(&tri1); + tri1.p[0].x = tipX; tri1.p[0].y = tipY; + tri1.p[1].x = rear1X; tri1.p[1].y = rear1Y; + tri1.p[2].x = rearCX; tri1.p[2].y = rearCY; + tri1.color = arrowColor; + tri1.opa = LV_OPA_COVER; + lv_draw_triangle(layer, &tri1); + + // Triangle 2: tip → rear2 → rearCenter + lv_draw_triangle_dsc_t tri2; + lv_draw_triangle_dsc_init(&tri2); + tri2.p[0].x = tipX; tri2.p[0].y = tipY; + tri2.p[1].x = rear2X; tri2.p[1].y = rear2Y; + tri2.p[2].x = rearCX; tri2.p[2].y = rearCY; + tri2.color = arrowColor; + tri2.opa = LV_OPA_COVER; + lv_draw_triangle(layer, &tri2); + } +} + +void buildGeocacheScreen() { + // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() + geocacheScr = lv_obj_create(NULL); + lv_obj_set_size(geocacheScr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(geocacheScr, FC_COLOR_BG, 0); + lv_obj_set_style_border_width(geocacheScr, 0, 0); + lv_obj_set_style_radius(geocacheScr, 0, 0); + lv_obj_set_style_pad_all(geocacheScr, 0, 0); + lv_obj_clear_flag(geocacheScr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(geocacheScr, screenGestureCb, LV_EVENT_GESTURE, NULL); + + // Nav sub-screen container (sub 0) + geocacheNavCtr = lv_obj_create(geocacheScr); + lv_obj_remove_style_all(geocacheNavCtr); + lv_obj_set_size(geocacheNavCtr, SCREEN_W, SCREEN_H); + lv_obj_set_pos(geocacheNavCtr, 0, 0); + lv_obj_clear_flag(geocacheNavCtr, LV_OBJ_FLAG_SCROLLABLE); + + // === Nav Sub-screen widgets (sub 0) === + gcNavHeader = fcHeaderCreate(geocacheNavCtr, "GEOCACHE"); + + // Cache name (centered) + gcNavLblName = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblName, 0, 33); + lv_obj_set_width(gcNavLblName, SCREEN_W); + lv_obj_set_style_text_font(gcNavLblName, FC_FONT_LG, 0); + lv_obj_set_style_text_color(gcNavLblName, FC_COLOR_TEXT, 0); + lv_obj_set_style_text_align(gcNavLblName, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(gcNavLblName, LV_LABEL_LONG_DOT); // Clip with "..." to prevent overlap + lv_label_set_text(gcNavLblName, "No cache loaded"); + + // Distance + gcNavLblDist = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblDist, 8, 57); + lv_obj_set_style_text_font(gcNavLblDist, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); + lv_label_set_text(gcNavLblDist, "--"); + + // Difficulty / Terrain + gcNavLblDT = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblDT, 140, 57); + lv_obj_set_style_text_font(gcNavLblDT, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcNavLblDT, FC_COLOR_DIM, 0); + lv_label_set_text(gcNavLblDT, ""); + + // Bearing + gcNavLblBearing = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblBearing, 300, 57); + lv_obj_set_style_text_font(gcNavLblBearing, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcNavLblBearing, FC_COLOR_VALUE, 0); + lv_label_set_text(gcNavLblBearing, ""); + + // Nav graphic area (custom draw — triangle or search zone) + gcNavGraphicObj = lv_obj_create(geocacheNavCtr); + lv_obj_remove_style_all(gcNavGraphicObj); + lv_obj_set_size(gcNavGraphicObj, 200, 120); + lv_obj_set_pos(gcNavGraphicObj, 140, 78); + lv_obj_clear_flag(gcNavGraphicObj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(gcNavGraphicObj, geocacheNavDrawCb, LV_EVENT_DRAW_MAIN, NULL); + + // Pulse animation for search zone circle + static lv_anim_t pulseAnim; + lv_anim_init(&pulseAnim); + lv_anim_set_var(&pulseAnim, gcNavGraphicObj); + lv_anim_set_values(&pulseAnim, 0, 8); + lv_anim_set_duration(&pulseAnim, 1000); + lv_anim_set_playback_duration(&pulseAnim, 1000); + lv_anim_set_repeat_count(&pulseAnim, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_exec_cb(&pulseAnim, [](void* obj, int32_t val) { + gcNavPulseRadius = val; + if (geocacheSubScreen == 0 && gcNavLastInZone) { + lv_obj_invalidate((lv_obj_t*)obj); + } + }); + lv_anim_start(&pulseAnim); + + // Accuracy + gcNavLblAccuracy = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblAccuracy, 0, 200); + lv_obj_set_width(gcNavLblAccuracy, SCREEN_W); + lv_obj_set_style_text_font(gcNavLblAccuracy, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcNavLblAccuracy, FC_COLOR_VALUE, 0); + lv_obj_set_style_text_align(gcNavLblAccuracy, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(gcNavLblAccuracy, ""); + + // Hint + gcNavLblHint = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavLblHint, 8, 222); + lv_obj_set_width(gcNavLblHint, SCREEN_W - 16); + lv_obj_set_style_text_font(gcNavLblHint, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcNavLblHint, FC_COLOR_DIM, 0); + lv_label_set_long_mode(gcNavLblHint, LV_LABEL_LONG_WRAP); + lv_label_set_text(gcNavLblHint, ""); + + // "List" touch label in header area — tap to go to cache list (#122) + lv_obj_t* gcNavListBtn = lv_label_create(geocacheNavCtr); + lv_obj_set_pos(gcNavListBtn, 200, 7); + lv_obj_set_style_text_font(gcNavListBtn, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcNavListBtn, FC_COLOR_BG, 0); // Black on cyan header + lv_label_set_text(gcNavListBtn, "[List]"); + lv_obj_add_flag(gcNavListBtn, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcNavListBtn, 15); // +15px invisible hit padding + lv_obj_add_event_cb(gcNavListBtn, gcNavListBtnCb, LV_EVENT_CLICKED, NULL); + + // Nav bar + gcNavNavBar = fcNavBarCreate(geocacheNavCtr, NUM_SCREENS, SCREEN_GEOCACHE); + + // List sub-screen container (sub 1) + geocacheListCtr = lv_obj_create(geocacheScr); + lv_obj_remove_style_all(geocacheListCtr); + lv_obj_set_size(geocacheListCtr, SCREEN_W, SCREEN_H); + lv_obj_set_pos(geocacheListCtr, 0, 0); + lv_obj_clear_flag(geocacheListCtr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); + + // === List Sub-screen widgets (sub 1) === + gcListHeader = fcHeaderCreate(geocacheListCtr, "CACHE LIST"); + + // Count label in header area + gcListLblCount = lv_label_create(geocacheListCtr); + lv_obj_set_pos(gcListLblCount, 360, 7); + lv_obj_set_style_text_font(gcListLblCount, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcListLblCount, FC_COLOR_DIM, 0); + lv_label_set_text(gcListLblCount, ""); + + // Scrollable list container + gcListScrollCtr = lv_obj_create(geocacheListCtr); + lv_obj_remove_style_all(gcListScrollCtr); + lv_obj_set_size(gcListScrollCtr, SCREEN_W, 228); + lv_obj_set_pos(gcListScrollCtr, 0, 33); + lv_obj_set_style_bg_color(gcListScrollCtr, FC_COLOR_BG, 0); + lv_obj_set_style_bg_opa(gcListScrollCtr, LV_OPA_COVER, 0); + lv_obj_add_flag(gcListScrollCtr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_row(gcListScrollCtr, 2, 0); + lv_obj_set_flex_flow(gcListScrollCtr, LV_FLEX_FLOW_COLUMN); + + // Pre-create all MAX_CACHES rows + for (int i = 0; i < MAX_CACHES; i++) { + gcListRows[i] = lv_obj_create(gcListScrollCtr); + lv_obj_remove_style_all(gcListRows[i]); + lv_obj_set_size(gcListRows[i], 460, 22); + lv_obj_clear_flag(gcListRows[i], LV_OBJ_FLAG_SCROLLABLE); + + // Single formatted label per row (crash fix: -80 LVGL objects) + gcListRowLabel[i] = lv_label_create(gcListRows[i]); + lv_obj_set_pos(gcListRowLabel[i], 4, 2); + lv_obj_set_width(gcListRowLabel[i], 452); + lv_obj_set_style_text_font(gcListRowLabel[i], FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcListRowLabel[i], FC_COLOR_TEXT, 0); + lv_label_set_long_mode(gcListRowLabel[i], LV_LABEL_LONG_CLIP); + lv_label_set_text(gcListRowLabel[i], ""); + + // Tap row to select (#122) + lv_obj_add_flag(gcListRows[i], LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(gcListRows[i], gcListRowTapCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + + // Hide rows beyond current cache count + lv_obj_add_flag(gcListRows[i], LV_OBJ_FLAG_HIDDEN); + } + + // Button hints + gcListLblHints = lv_label_create(geocacheListCtr); + lv_obj_set_pos(gcListLblHints, 0, 265); + lv_obj_set_width(gcListLblHints, SCREEN_W); + lv_obj_set_style_text_font(gcListLblHints, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcListLblHints, FC_COLOR_DIM, 0); + lv_obj_set_style_text_align(gcListLblHints, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(gcListLblHints, "[A]Up [B]Down [C]Select [C+]Details"); + + // Active filter indicator in header area (#122) + gcFiltLblActive = lv_label_create(geocacheListCtr); + lv_obj_set_pos(gcFiltLblActive, 180, 7); + lv_obj_set_style_text_font(gcFiltLblActive, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcFiltLblActive, FC_COLOR_WARN, 0); + lv_label_set_text(gcFiltLblActive, ""); + + // Filter button (touchable label in header area) (#122) + lv_obj_t* gcFilterBtn = lv_label_create(geocacheListCtr); + lv_obj_set_pos(gcFilterBtn, 300, 7); + lv_obj_set_style_text_font(gcFilterBtn, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcFilterBtn, FC_COLOR_BG, 0); // Black on cyan header + lv_label_set_text(gcFilterBtn, "[Filter]"); + lv_obj_add_flag(gcFilterBtn, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFilterBtn, 15); + lv_obj_add_event_cb(gcFilterBtn, gcFilterBtnCb, LV_EVENT_CLICKED, NULL); + + // Nav bar + gcListNavBar = fcNavBarCreate(geocacheListCtr, NUM_SCREENS, SCREEN_GEOCACHE); + + // Details sub-screen container (sub 2) + geocacheDetailsCtr = lv_obj_create(geocacheScr); + lv_obj_remove_style_all(geocacheDetailsCtr); + lv_obj_set_size(geocacheDetailsCtr, SCREEN_W, SCREEN_H); + lv_obj_set_pos(geocacheDetailsCtr, 0, 0); + lv_obj_clear_flag(geocacheDetailsCtr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); + + // === Details Sub-screen widgets (sub 2) === + gcDetHeader = fcHeaderCreate(geocacheDetailsCtr, "CACHE DETAILS"); + + // Count in header area + gcDetLblCount = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblCount, 360, 7); + lv_obj_set_style_text_font(gcDetLblCount, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcDetLblCount, FC_COLOR_DIM, 0); + lv_label_set_text(gcDetLblCount, ""); + + // Cache name + gcDetLblName = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblName, 8, 35); + lv_obj_set_width(gcDetLblName, SCREEN_W - 16); + lv_obj_set_style_text_font(gcDetLblName, FC_FONT_LG, 0); + lv_obj_set_style_text_color(gcDetLblName, FC_COLOR_TEXT, 0); + lv_label_set_long_mode(gcDetLblName, LV_LABEL_LONG_CLIP); + lv_label_set_text(gcDetLblName, ""); + + // GC code + gcDetLblGC = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblGC, 8, 57); + lv_obj_set_style_text_font(gcDetLblGC, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcDetLblGC, FC_COLOR_HEADER, 0); + lv_label_set_text(gcDetLblGC, ""); + + // Coordinates + gcDetLblCoords = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblCoords, 8, 79); + lv_obj_set_style_text_font(gcDetLblCoords, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcDetLblCoords, FC_COLOR_VALUE, 0); + lv_label_set_text(gcDetLblCoords, ""); + + // Difficulty/Terrain + gcDetLblDT = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblDT, 8, 97); + lv_obj_set_style_text_font(gcDetLblDT, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcDetLblDT, FC_COLOR_DIM, 0); + lv_label_set_text(gcDetLblDT, ""); + + // Distance + bearing (dynamic) + gcDetLblDist = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblDist, 8, 119); + lv_obj_set_style_text_font(gcDetLblDist, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcDetLblDist, FC_COLOR_VALUE, 0); + lv_label_set_text(gcDetLblDist, ""); + + // Hint label + gcDetLblHintLabel = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblHintLabel, 8, 141); + lv_obj_set_style_text_font(gcDetLblHintLabel, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcDetLblHintLabel, FC_COLOR_DIM, 0); + lv_label_set_text(gcDetLblHintLabel, "Hint:"); + + // Hint text (wrapped) + gcDetLblHint = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblHint, 8, 159); + lv_obj_set_width(gcDetLblHint, SCREEN_W - 16); + lv_obj_set_style_text_font(gcDetLblHint, FC_FONT_SM, 0); + lv_obj_set_style_text_color(gcDetLblHint, FC_COLOR_DIM, 0); + lv_label_set_long_mode(gcDetLblHint, LV_LABEL_LONG_WRAP); + lv_label_set_text(gcDetLblHint, ""); + + // Found status + gcDetLblFound = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblFound, 0, 200); + lv_obj_set_width(gcDetLblFound, SCREEN_W); + lv_obj_set_style_text_font(gcDetLblFound, FC_FONT_MD, 0); + lv_obj_set_style_text_align(gcDetLblFound, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(gcDetLblFound, ""); + + // Button hints + gcDetLblHints = lv_label_create(geocacheDetailsCtr); + lv_obj_set_pos(gcDetLblHints, 0, 265); + lv_obj_set_width(gcDetLblHints, SCREEN_W); + lv_obj_set_style_text_font(gcDetLblHints, FC_FONT_XS, 0); + lv_obj_set_style_text_color(gcDetLblHints, FC_COLOR_DIM, 0); + lv_obj_set_style_text_align(gcDetLblHints, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(gcDetLblHints, "[A]Prev [B]Next [C]Toggle [C+]Back"); + + // Nav bar + gcDetNavBar = fcNavBarCreate(geocacheDetailsCtr, NUM_SCREENS, SCREEN_GEOCACHE); + + // === Filter sub-screen container (sub 3) (#122) === + // Minimal object count — no fcHeaderCreate, tap-to-cycle values + geocacheFilterCtr = lv_obj_create(geocacheScr); + lv_obj_remove_style_all(geocacheFilterCtr); + lv_obj_set_size(geocacheFilterCtr, SCREEN_W, SCREEN_H); + lv_obj_set_pos(geocacheFilterCtr, 0, 0); + lv_obj_set_style_bg_color(geocacheFilterCtr, FC_COLOR_BG, 0); + lv_obj_set_style_bg_opa(geocacheFilterCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(geocacheFilterCtr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); + + // Title (simple label, not full header — saves 4 objects) + lv_obj_t* filtTitle = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(filtTitle, 12, 8); + lv_obj_set_style_text_font(filtTitle, FC_FONT_MD, 0); + lv_obj_set_style_text_color(filtTitle, FC_COLOR_HEADER, 0); + lv_label_set_text(filtTitle, "FILTER CACHES"); + + // Each row: tappable label that cycles value. No separate [-][+] buttons. + // Found: tap cycles All→Unfound→Found + gcFiltLblFound = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(gcFiltLblFound, 12, 45); + lv_obj_set_style_text_font(gcFiltLblFound, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcFiltLblFound, FC_COLOR_HEADER, 0); + lv_label_set_text(gcFiltLblFound, "Found: All"); + lv_obj_add_flag(gcFiltLblFound, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFiltLblFound, 10); + lv_obj_add_event_cb(gcFiltLblFound, gcFilterFoundCb, LV_EVENT_CLICKED, NULL); + + // Sort: tap cycles Distance→Name + gcFiltLblSort = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(gcFiltLblSort, 12, 80); + lv_obj_set_style_text_font(gcFiltLblSort, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcFiltLblSort, FC_COLOR_HEADER, 0); + lv_label_set_text(gcFiltLblSort, "Sort: Distance"); + lv_obj_add_flag(gcFiltLblSort, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFiltLblSort, 10); + lv_obj_add_event_cb(gcFiltLblSort, gcFilterSortCb, LV_EVENT_CLICKED, NULL); + + // D range: tap cycles through preset ranges + gcFiltLblDRange = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(gcFiltLblDRange, 12, 115); + lv_obj_set_style_text_font(gcFiltLblDRange, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcFiltLblDRange, FC_COLOR_HEADER, 0); + lv_label_set_text(gcFiltLblDRange, "D: 1.0 - 5.0"); + lv_obj_add_flag(gcFiltLblDRange, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFiltLblDRange, 10); + lv_obj_add_event_cb(gcFiltLblDRange, gcFilterDMinUpCb, LV_EVENT_CLICKED, NULL); + + // T range: tap cycles through preset ranges + gcFiltLblTRange = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(gcFiltLblTRange, 12, 150); + lv_obj_set_style_text_font(gcFiltLblTRange, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcFiltLblTRange, FC_COLOR_HEADER, 0); + lv_label_set_text(gcFiltLblTRange, "T: 1.0 - 5.0"); + lv_obj_add_flag(gcFiltLblTRange, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFiltLblTRange, 10); + lv_obj_add_event_cb(gcFiltLblTRange, gcFilterTMinUpCb, LV_EVENT_CLICKED, NULL); + + // Distance: tap cycles All→1mi→5mi→10mi→25mi + gcFiltLblDist = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(gcFiltLblDist, 12, 185); + lv_obj_set_style_text_font(gcFiltLblDist, FC_FONT_MD, 0); + lv_obj_set_style_text_color(gcFiltLblDist, FC_COLOR_HEADER, 0); + lv_label_set_text(gcFiltLblDist, "Dist: All"); + lv_obj_add_flag(gcFiltLblDist, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(gcFiltLblDist, 10); + lv_obj_add_event_cb(gcFiltLblDist, gcFilterDistCb, LV_EVENT_CLICKED, NULL); + + // Reset + Back on same row + lv_obj_t* resetBtn = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(resetBtn, 12, 230); + lv_obj_set_style_text_font(resetBtn, FC_FONT_MD, 0); + lv_obj_set_style_text_color(resetBtn, FC_COLOR_WARN, 0); + lv_label_set_text(resetBtn, "[Reset All]"); + lv_obj_add_flag(resetBtn, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(resetBtn, 10); + lv_obj_add_event_cb(resetBtn, gcFilterResetCb, LV_EVENT_CLICKED, NULL); + + lv_obj_t* backBtn = lv_label_create(geocacheFilterCtr); + lv_obj_set_pos(backBtn, 350, 230); + lv_obj_set_style_text_font(backBtn, FC_FONT_MD, 0); + lv_obj_set_style_text_color(backBtn, FC_COLOR_HEADER, 0); + lv_label_set_text(backBtn, "[Back]"); + lv_obj_add_flag(backBtn, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(backBtn, 10); + lv_obj_add_event_cb(backBtn, gcFilterBackCb, LV_EVENT_CLICKED, NULL); + + logPrintln("[LVGL] Geocache screen built (#110)"); +} + +void updateGeocacheData() { + if (!geocacheScr) return; + + // Sub-screen visibility switching (#122: added sub 3 filter) + lv_obj_add_flag(geocacheNavCtr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); + if (geocacheSubScreen == 0) lv_obj_clear_flag(geocacheNavCtr, LV_OBJ_FLAG_HIDDEN); + else if (geocacheSubScreen == 1) lv_obj_clear_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); + else if (geocacheSubScreen == 2) lv_obj_clear_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); + else if (geocacheSubScreen == 3) lv_obj_clear_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); + + // === NAV sub-screen data (sub 0) === + if (geocacheSubScreen == 0) { + if (cacheListCount == 0 || !cacheList[selectedCacheIndex].valid) { + lv_label_set_text(gcNavLblName, "No cache loaded"); + lv_label_set_text(gcNavLblDist, ""); + lv_label_set_text(gcNavLblDT, ""); + lv_label_set_text(gcNavLblBearing, ""); + lv_label_set_text(gcNavLblAccuracy, ""); + lv_label_set_text(gcNavLblHint, "Upload caches via web interface"); + return; + } + + GeocacheEntry& cache = cacheList[selectedCacheIndex]; + lv_label_set_text(gcNavLblName, cache.name); + lv_label_set_text_fmt(gcNavLblDT, "D:%.1f T:%.1f", cache.difficulty, cache.terrain); + + if (gpsData.valid) { + float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + float distM = distKm * 1000.0f; + float bearing = calcBearing(gpsData.latitude, gpsData.longitude, + cache.latitude, cache.longitude); + float accuracyM = getGpsAccuracyMeters(); + bool inSearchZone = (distM < accuracyM); + + // Distance + if (inSearchZone) { + lv_label_set_text(gcNavLblDist, "SEARCH ZONE"); + lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_WARN, 0); + } else if (useMetricUnits) { + if (distKm >= 1.0f) lv_label_set_text_fmt(gcNavLblDist, "%.1f km", distKm); + else lv_label_set_text_fmt(gcNavLblDist, "%d m", (int)distM); + lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); + } else { + float distMi = distKm * 0.621371f; + float distFt = distM * 3.28084f; + if (distMi >= 0.1f) lv_label_set_text_fmt(gcNavLblDist, "%.1f mi", distMi); + else lv_label_set_text_fmt(gcNavLblDist, "%d ft", (int)distFt); + lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); + } + + // Bearing + lv_label_set_text_fmt(gcNavLblBearing, "%d\xC2\xB0", (int)bearing); + + // Accuracy (color-coded) + if (useMetricUnits) { + lv_label_set_text_fmt(gcNavLblAccuracy, "+/-%dm", (int)accuracyM); + } else { + lv_label_set_text_fmt(gcNavLblAccuracy, "+/-%dft", (int)(accuracyM * 3.28084f)); + } + lv_obj_set_style_text_color(gcNavLblAccuracy, getLvglAccuracyColor(accuracyM), 0); + + // Hint (full in search zone, truncated otherwise) + if (inSearchZone) { + lv_label_set_text(gcNavLblHint, cache.hint); + } else { + char hintPreview[32]; + strncpy(hintPreview, cache.hint, 30); + hintPreview[30] = '\0'; + if (strlen(cache.hint) > 30) strcat(hintPreview, ".."); + lv_label_set_text(gcNavLblHint, hintPreview); + } + + // Invalidate nav graphic on bearing/heading change (2° threshold) + float bDiff = fabs(bearing - gcNavLastBearing); + float hDiff = fabs(imuData.heading - gcNavLastHeading); + if (bDiff > 180) bDiff = 360 - bDiff; + if (hDiff > 180) hDiff = 360 - hDiff; + if (bDiff >= 2.0f || hDiff >= 2.0f || inSearchZone != gcNavLastInZone) { + gcNavLastBearing = bearing; + gcNavLastHeading = imuData.heading; + gcNavLastInZone = inSearchZone; + lv_obj_invalidate(gcNavGraphicObj); + } + } else { + lv_label_set_text(gcNavLblDist, "Acquiring GPS..."); + lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_WARN, 0); + lv_label_set_text(gcNavLblDT, ""); // Clear to prevent overlap with dist text + lv_label_set_text(gcNavLblBearing, ""); + lv_label_set_text(gcNavLblAccuracy, ""); + } + fcNavBarSetActive(gcNavNavBar, currentScreen); + } + + // === LIST sub-screen data (sub 1) === + if (geocacheSubScreen == 1) { + // Periodic re-sort every 30s when GPS valid (#122) + if (gpsData.valid && cacheListCount > 0 && millis() - gcLastSortTime > 30000) { + gcApplyFilters(); + } + + // Clamp highlight to filtered range + if (gcFilteredCount > 0 && listHighlightIndex >= gcFilteredCount) + listHighlightIndex = gcFilteredCount - 1; + + // Count label: show filtered/total + if (gcFilteredCount < cacheListCount) + lv_label_set_text_fmt(gcListLblCount, "[%d/%d shown]", gcFilteredCount, cacheListCount); + else + lv_label_set_text_fmt(gcListLblCount, "[%d/%d]", listHighlightIndex + 1, cacheListCount); + + // Active filter indicator (#122) + bool hasFilter = (gcFilterFoundMode != 0 || gcFilterDMin > 1.0f || gcFilterDMax < 5.0f || + gcFilterTMin > 1.0f || gcFilterTMax < 5.0f || gcFilterMaxDistKm > 0 || + gcSortMode != 0); + lv_label_set_text(gcFiltLblActive, hasFilter ? "[FILTERED]" : ""); + + for (int r = 0; r < MAX_CACHES; r++) { + if (r >= gcFilteredCount) { + lv_obj_add_flag(gcListRows[r], LV_OBJ_FLAG_HIDDEN); + continue; + } + lv_obj_clear_flag(gcListRows[r], LV_OBJ_FLAG_HIDDEN); + int ci = gcFilteredIndices[r]; + GeocacheEntry& c = cacheList[ci]; + + // Highlight row background + if (r == listHighlightIndex) { + lv_obj_set_style_bg_color(gcListRows[r], lv_color_hex(0x1A1A2E), 0); + lv_obj_set_style_bg_opa(gcListRows[r], LV_OPA_COVER, 0); + lv_obj_set_style_text_color(gcListRowLabel[r], FC_COLOR_HEADER, 0); + } else { + lv_obj_set_style_bg_opa(gcListRows[r], LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(gcListRowLabel[r], FC_COLOR_TEXT, 0); + } + + // Format single row string: "> 2.3mi great white * D:2 T:2" + char distBuf[10]; + if (gpsData.valid) { + float dk = gcCachedDist[ci]; + if (useMetricUnits) { + if (dk >= 1.0f) snprintf(distBuf, sizeof(distBuf), "%.1fkm", dk); + else snprintf(distBuf, sizeof(distBuf), "%dm", (int)(dk * 1000)); + } else { + float mi = dk * 0.621371f; + if (mi >= 0.1f) snprintf(distBuf, sizeof(distBuf), "%.1fmi", mi); + else snprintf(distBuf, sizeof(distBuf), "%dft", (int)(dk * 3280.84f)); + } + } else { + strcpy(distBuf, "--"); + } + char nameBuf[20]; + strncpy(nameBuf, c.name, 16); + nameBuf[16] = '\0'; + if (strlen(c.name) > 16) { nameBuf[14] = '.'; nameBuf[15] = '.'; nameBuf[16] = '\0'; } + + char rowBuf[64]; + snprintf(rowBuf, sizeof(rowBuf), "%c %-7s %-16s %c D:%d T:%d", + (r == listHighlightIndex) ? '>' : ' ', + distBuf, nameBuf, + c.found ? '*' : ' ', + (int)c.difficulty, (int)c.terrain); + lv_label_set_text(gcListRowLabel[r], rowBuf); + } + + // Scroll highlighted row into view + if (listHighlightIndex < gcFilteredCount) { + lv_obj_scroll_to_view(gcListRows[listHighlightIndex], LV_ANIM_ON); + } + fcNavBarSetActive(gcListNavBar, currentScreen); + } + + // === DETAILS sub-screen data (sub 2) === + if (geocacheSubScreen == 2) { + if (gcFilteredCount == 0 || listHighlightIndex >= gcFilteredCount) return; + int ci = gcFilteredIndices[listHighlightIndex]; + GeocacheEntry& c = cacheList[ci]; + + lv_label_set_text_fmt(gcDetLblCount, "[%d/%d]", listHighlightIndex + 1, gcFilteredCount); + lv_label_set_text(gcDetLblName, c.name); + lv_label_set_text(gcDetLblGC, c.gcCode); + lv_label_set_text_fmt(gcDetLblCoords, "%.4f%c %.4f%c", + fabs(c.latitude), c.latitude >= 0 ? 'N' : 'S', + fabs(c.longitude), c.longitude >= 0 ? 'E' : 'W'); + lv_label_set_text_fmt(gcDetLblDT, "Difficulty: %.1f Terrain: %.1f", + c.difficulty, c.terrain); + + // Dynamic distance + if (gpsData.valid) { + float dk = calcDistanceKm(gpsData.latitude, gpsData.longitude, + c.latitude, c.longitude); + float bearing = calcBearing(gpsData.latitude, gpsData.longitude, + c.latitude, c.longitude); + if (useMetricUnits) { + lv_label_set_text_fmt(gcDetLblDist, "%.2f km Bearing: %d\xC2\xB0", dk, (int)bearing); + } else { + lv_label_set_text_fmt(gcDetLblDist, "%.2f mi Bearing: %d\xC2\xB0", + dk * 0.621371f, (int)bearing); + } + } else { + lv_label_set_text(gcDetLblDist, "GPS not available"); + } + + lv_label_set_text(gcDetLblHint, c.hint); + + // Found status (color-coded) + if (c.found) { + lv_label_set_text(gcDetLblFound, "[* FOUND]"); + lv_obj_set_style_text_color(gcDetLblFound, FC_COLOR_VALUE, 0); + } else { + lv_label_set_text(gcDetLblFound, "[ NOT FOUND ]"); + lv_obj_set_style_text_color(gcDetLblFound, FC_COLOR_DIM, 0); + } + fcNavBarSetActive(gcDetNavBar, currentScreen); + } +} + +// Nav screen → list touch callback (#122) +static void gcNavListBtnCb(lv_event_t* e) { + (void)e; + geocacheSubScreen = 1; + listHighlightIndex = 0; + listScrollOffset = 0; +} + +// List row tap callback (#122): tap to highlight, tap highlighted row to select+nav +static void gcListRowTapCb(lv_event_t* e) { + int row = (int)(intptr_t)lv_event_get_user_data(e); + if (row < 0 || row >= gcFilteredCount) return; + if (row == listHighlightIndex) { + // Already highlighted — select and go to nav + selectedCacheIndex = gcFilteredIndices[row]; + geocacheSubScreen = 0; + } else { + // First tap — just highlight this row + listHighlightIndex = row; + } +} + +// Helper: update filter screen labels to reflect current state +void gcUpdateFilterLabels() { + const char* foundLabels[] = {"All", "Unfound", "Found"}; + lv_label_set_text_fmt(gcFiltLblFound, "Found: %s", foundLabels[gcFilterFoundMode]); + lv_label_set_text_fmt(gcFiltLblSort, "Sort: %s", gcSortMode == 0 ? "Distance" : "Name"); + // D range: show as ints when whole, floats otherwise + int dMinI = (int)(gcFilterDMin * 10), dMaxI = (int)(gcFilterDMax * 10); + if (dMinI % 10 == 0 && dMaxI % 10 == 0) + lv_label_set_text_fmt(gcFiltLblDRange, "D: %d - %d", (int)gcFilterDMin, (int)gcFilterDMax); + else + lv_label_set_text_fmt(gcFiltLblDRange, "D: %.1f - %.1f", gcFilterDMin, gcFilterDMax); + int tMinI = (int)(gcFilterTMin * 10), tMaxI = (int)(gcFilterTMax * 10); + if (tMinI % 10 == 0 && tMaxI % 10 == 0) + lv_label_set_text_fmt(gcFiltLblTRange, "T: %d - %d", (int)gcFilterTMin, (int)gcFilterTMax); + else + lv_label_set_text_fmt(gcFiltLblTRange, "T: %.1f - %.1f", gcFilterTMin, gcFilterTMax); + if (gcFilterMaxDistKm <= 0) { + lv_label_set_text(gcFiltLblDist, "Dist: All"); + } else if (useMetricUnits) { + lv_label_set_text_fmt(gcFiltLblDist, "Dist: %.0fkm", gcFilterMaxDistKm); + } else { + lv_label_set_text_fmt(gcFiltLblDist, "Dist: %.0fmi", gcFilterMaxDistKm * 0.621371f); + } +} + +static void gcFilterBtnCb(lv_event_t* e) { + (void)e; + geocacheSubScreen = 3; + gcUpdateFilterLabels(); +} + +static void gcFilterBackCb(lv_event_t* e) { + (void)e; + gcApplyFilters(); + listHighlightIndex = 0; + geocacheSubScreen = 1; +} + +static void gcFilterFoundCb(lv_event_t* e) { + (void)e; + gcFilterFoundMode = (gcFilterFoundMode + 1) % 3; + gcUpdateFilterLabels(); +} + +static void gcFilterSortCb(lv_event_t* e) { + (void)e; + gcSortMode = (gcSortMode + 1) % 2; + gcUpdateFilterLabels(); +} + +static void gcFilterDMinUpCb(lv_event_t* e) { + (void)e; + gcDPresetIdx = (gcDPresetIdx + 1) % GC_DT_PRESET_COUNT; + gcFilterDMin = gcDTPresets[gcDPresetIdx][0]; + gcFilterDMax = gcDTPresets[gcDPresetIdx][1]; + gcUpdateFilterLabels(); +} + +static void gcFilterTMinUpCb(lv_event_t* e) { + (void)e; + gcTPresetIdx = (gcTPresetIdx + 1) % GC_DT_PRESET_COUNT; + gcFilterTMin = gcDTPresets[gcTPresetIdx][0]; + gcFilterTMax = gcDTPresets[gcTPresetIdx][1]; + gcUpdateFilterLabels(); +} + +static void gcFilterDistCb(lv_event_t* e) { + (void)e; + // Find current preset and cycle to next + int cur = 0; + for (int i = 0; i < GC_DIST_PRESET_COUNT; i++) { + if (fabs(gcFilterMaxDistKm - gcDistPresets[i]) < 0.1f) { cur = i; break; } + } + cur = (cur + 1) % GC_DIST_PRESET_COUNT; + gcFilterMaxDistKm = gcDistPresets[cur]; + gcUpdateFilterLabels(); +} + +static void gcFilterResetCb(lv_event_t* e) { + (void)e; + gcFilterFoundMode = 0; + gcFilterDMin = 1.0f; gcFilterDMax = 5.0f; + gcFilterTMin = 1.0f; gcFilterTMax = 5.0f; + gcFilterMaxDistKm = 0; + gcSortMode = 0; + gcDPresetIdx = 0; + gcTPresetIdx = 0; + gcUpdateFilterLabels(); +} + +// Handle geocache-specific button actions for list/details sub-screens +void handleGeocacheButtons(bool buttonA, bool buttonB) { + if (geocacheSubScreen == 1) { + // Cache List: A=scroll up, B=scroll down (filtered list) (#122) + if (buttonA && listHighlightIndex > 0) { + listHighlightIndex--; + if (listHighlightIndex < listScrollOffset) { + listScrollOffset = listHighlightIndex; + } + } + if (buttonB && listHighlightIndex < gcFilteredCount - 1) { + listHighlightIndex++; + if (listHighlightIndex >= listScrollOffset + 5) { + listScrollOffset = listHighlightIndex - 4; + } + } + } else if (geocacheSubScreen == 2) { + // Cache Details: A=prev cache, B=next cache (filtered) (#122) + if (buttonA && listHighlightIndex > 0) { + listHighlightIndex--; + } + if (buttonB && listHighlightIndex < gcFilteredCount - 1) { + listHighlightIndex++; + } + } +} diff --git a/src/screen_geocache.h b/src/screen_geocache.h new file mode 100644 index 0000000..0c1f367 --- /dev/null +++ b/src/screen_geocache.h @@ -0,0 +1,11 @@ +#pragma once +// screen_geocache.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +extern lv_obj_t* geocacheScr; +extern lv_obj_t* gcNavHeader; + +void buildGeocacheScreen(); +void updateGeocacheData(); +void gcUpdateFilterLabels(); +void handleGeocacheButtons(bool buttonA, bool buttonB); diff --git a/src/screen_settings.cpp b/src/screen_settings.cpp new file mode 100644 index 0000000..310cf66 --- /dev/null +++ b/src/screen_settings.cpp @@ -0,0 +1,1038 @@ +// screen_settings.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "screen_settings.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "navigation.h" +#include "lvgl_port.h" +#include "ui_state.h" +#include "settings.h" +#include "fram.h" +#include "gps.h" +#include "imu.h" +#include "env.h" +#include "battery.h" +#include "weather.h" +#include "geocache.h" +#include "display.h" +#include "oled.h" +#include "touch.h" +#include "rtc.h" +#include "web.h" +#include "logging.h" +#include "fc_version.h" +#include "fc_config.h" + +static const char* settingsMenuItems[] = { + "Configuration", + "Display", + "Compass Cal", + "Diagnostics", + "About", + "Factory Reset" +}; +lv_obj_t* settingsScr = NULL; +static lv_obj_t* settingsMenuCtr = NULL; +static lv_obj_t* settingsConfigCtr = NULL; +static lv_obj_t* settingsDisplayCtr = NULL; +static lv_obj_t* settingsCalCtr = NULL; +static lv_obj_t* settingsDiagsCtr = NULL; +static lv_obj_t* settingsAboutCtr = NULL; +static lv_obj_t* settingsResetCtr = NULL; +static lv_obj_t* settingsMenuBtns[6]; // moved by hand (#265): written, never read, so nm never listed it +lv_obj_t* settingsScrollAreas[7] = {NULL}; +static lv_obj_t* cfgTzDropdown = NULL; +static lv_obj_t* cfgTimeToggle = NULL; +static lv_obj_t* cfgTempToggle = NULL; +static lv_obj_t* cfgDistToggle = NULL; +static lv_obj_t* cfgPreviewLabel = NULL; +static lv_obj_t* dispBrightnessSlider = NULL; +static lv_obj_t* dispBrightnessLabel = NULL; +static lv_obj_t* dispTftDropdown = NULL; +static lv_obj_t* dispOledDropdown = NULL; +static lv_obj_t* calStatusLabel = NULL; +static lv_obj_t* calOffsetsLabel = NULL; +static lv_obj_t* calStartBtn = NULL; +static lv_obj_t* calIdleActBar = NULL; +static lv_obj_t* calArc = NULL; +static lv_obj_t* calCountdownLabel = NULL; +static lv_obj_t* calInstructLabel = NULL; +static lv_obj_t* calMinMaxLabel = NULL; +static lv_obj_t* diagValueLabels[11]; // 11 value labels updated each frame +static lv_obj_t* aboutValueLabels[6]; // 6 value labels (some live-updating) + +// Helper: create a scrollable content area between header and action bar (#101) +static lv_obj_t* fcSettingsScrollCreate(lv_obj_t* parent, int idx) { + lv_obj_t* scroll = lv_obj_create(parent); + lv_obj_remove_style_all(scroll); + lv_obj_set_size(scroll, SCREEN_W, 240); // 320 - 30 header - 50 action bar + lv_obj_set_pos(scroll, 0, 30); + lv_obj_set_style_bg_opa(scroll, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(scroll, 0, 0); + lv_obj_set_style_pad_all(scroll, 0, 0); + // Vertical scroll only, auto-show scrollbar when content overflows + lv_obj_set_scroll_dir(scroll, LV_DIR_VER); + lv_obj_set_scrollbar_mode(scroll, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_style_bg_color(scroll, lv_color_hex(0x808080), LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(scroll, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(scroll, 4, LV_PART_SCROLLBAR); + lv_obj_clear_flag(scroll, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_CLICKABLE)); + settingsScrollAreas[idx] = scroll; + return scroll; +} + +// LVGL heap diagnostic — log memory state on Settings interactions (#119) +static void logLvglHeap(const char* context) { + lv_mem_monitor_t mon; + lv_mem_monitor(&mon); + logPrintf("[LVGL/MEM] %s: free=%lu used=%d%% frag=%d%%\n", + context, (unsigned long)mon.free_size, + mon.used_pct, mon.frag_pct); + // Warn if dangerously low + if (mon.free_size < 8192) { + logPrintf("[LVGL/MEM] WARNING: <8KB free! Alloc may fail.\n"); + } +} + +// Settings menu button click — navigate to sub-screen +static void settingsMenuBtnCb(lv_event_t* e) { + lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e); + int subScreen = (int)(intptr_t)lv_obj_get_user_data(btn); + settingsSubScreen = subScreen; + logPrintf("[SETTINGS/LVGL] Menu → sub-screen %d\n", subScreen); + logLvglHeap("menu→sub"); // Track heap on every navigation (#119) +} + +// Settings Back button — return to previous screen +static void settingsBackToScreenCb(lv_event_t* e) { + (void)e; + logLvglHeap("settings→exit"); // Log heap BEFORE save+exit (#119) + navigateFromSettings(); +} + +// Timezone picker selection — fired by fcListPickerOpen via LV_EVENT_VALUE_CHANGED on cfgTzDropdown +static void cfgTzSelectedCb(lv_event_t* e) { + int idx = (int)(intptr_t)lv_event_get_param(e); + if (idx < 0 || idx >= TZ_PRESET_COUNT) return; + tzSelectedIndex = idx; + strncpy(posixTZ, tzPresets[idx].posix, sizeof(posixTZ) - 1); + strncpy(tzDisplayName, tzPresets[idx].name, sizeof(tzDisplayName) - 1); + fcDropdownSetValue(cfgTzDropdown, idx, tzDisplayName); + logPrintf("[CONFIG/LVGL] TZ selected: %s\n", tzDisplayName); +} + +// Timezone dropdown click — opens list picker for TZ selection (#117/#119) +static void cfgTzDropdownCb(lv_event_t* e) { + (void)e; + logPrintf("[CONFIG/LVGL] TZ dropdown CLICKED — opening picker\n"); + static const char* tzNames[TZ_PRESET_COUNT]; + for (int i = 0; i < TZ_PRESET_COUNT; i++) { + tzNames[i] = tzPresets[i].name; + } + lv_obj_t* picker = fcListPickerOpen("Time Zone", tzNames, TZ_PRESET_COUNT, + tzSelectedIndex, cfgTzDropdown); + if (!picker) { + logPrintf("[CONFIG/LVGL] TZ picker FAILED to open (OOM?)\n"); + } +} + +// Toggle change — update globals immediately for live preview +static void cfgToggleCb(lv_event_t* e) { + (void)e; + use12Hour = (fcToggleGetValue(cfgTimeToggle) == 0); // 0=12Hour + useFahrenheit = (fcToggleGetValue(cfgTempToggle) == 0); // 0=degF + useMetricUnits = (fcToggleGetValue(cfgDistToggle) == 1); // 1=Metric +} + +// Config Back — discard changes, reload from SD +static void cfgBackCb(lv_event_t* e) { + (void)e; + logLvglHeap("cfg←back"); // (#119) + loadSettings(); // Discard changes, reload from SD + // Reset toggle visuals to match reloaded values + fcToggleSetValue(cfgTimeToggle, use12Hour ? 0 : 1); + fcToggleSetValue(cfgTempToggle, useFahrenheit ? 0 : 1); + fcToggleSetValue(cfgDistToggle, useMetricUnits ? 1 : 0); + fcDropdownSetValue(cfgTzDropdown, tzSelectedIndex, tzDisplayName); + settingsSubScreen = 0; +} + +// Config OK — apply and save +static void cfgOKCb(lv_event_t* e) { + (void)e; + logLvglHeap("cfg←ok"); // (#119) + applyTimezone(); + saveSettings(); + settingsSubScreen = 0; +} + +// Display sub-screen callbacks (#112) +static void dispBrightnessChangedCb(lv_event_t* e) { + lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); + int val = lv_slider_get_value(slider); + tftBrightness = (uint8_t)val; + analogWrite(TFT_BL, tftBrightness); + if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", val); +} + +static void dispTftDropdownCb(lv_event_t* e) { + (void)e; + logPrintf("[DISPLAY/LVGL] TFT dropdown CLICKED\n"); + int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); + fcListPickerOpen("TFT Sleep", tftTimeoutLabels, TFT_TIMEOUT_COUNT, idx, dispTftDropdown); +} + +static void dispTftSelectedCb(lv_event_t* e) { + int idx = (int)(intptr_t)lv_event_get_param(e); + if (idx < 0 || idx >= TFT_TIMEOUT_COUNT) return; + tftSleepMs = tftTimeoutPresets[idx]; + fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); + logPrintf("[DISPLAY/LVGL] TFT sleep: %s\n", tftTimeoutLabels[idx]); +} + +static void dispOledDropdownCb(lv_event_t* e) { + (void)e; + logPrintf("[DISPLAY/LVGL] OLED dropdown CLICKED\n"); + int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); + fcListPickerOpen("OLED Sleep", oledTimeoutLabels, OLED_TIMEOUT_COUNT, idx, dispOledDropdown); +} + +static void dispOledSelectedCb(lv_event_t* e) { + int idx = (int)(intptr_t)lv_event_get_param(e); + if (idx < 0 || idx >= OLED_TIMEOUT_COUNT) return; + oledSleepMs = oledTimeoutPresets[idx]; + fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); + logPrintf("[DISPLAY/LVGL] OLED sleep: %s\n", oledTimeoutLabels[idx]); +} + +static void dispBackCb(lv_event_t* e) { + (void)e; + logLvglHeap("disp←back"); // (#119) + loadSettings(); // Restore saved values + analogWrite(TFT_BL, tftBrightness); // Apply restored brightness + // Sync slider + label to restored values + if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); + if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); + // Sync dropdown labels + if (dispTftDropdown) { + int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); + fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); + } + if (dispOledDropdown) { + int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); + fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); + } + settingsSubScreen = 0; +} + +static void dispOKCb(lv_event_t* e) { + (void)e; + logLvglHeap("disp←ok"); // (#119) + saveSettings(); + settingsSubScreen = 0; +} + +// Compass Cal sub-screen callbacks (#112) +static void calStartBtnCb(lv_event_t* e) { + (void)e; + if (!magAvailable || magCalibrating) return; + magCalibrating = true; + magCalStartTime = millis(); + magCalMinX = magCalMinY = magCalMinZ = 99999; + magCalMaxX = magCalMaxY = magCalMaxZ = -99999; + logPrintln("[MAG/LVGL] Calibration started"); +} + +static void calBackCb(lv_event_t* e) { + (void)e; + if (magCalibrating) { + magCalibrating = false; + logPrintln("[MAG/LVGL] Calibration cancelled"); + } + settingsSubScreen = 0; +} + +// Diagnostics sub-screen callback (#112) +static void diagsBackCb(lv_event_t* e) { + (void)e; + settingsSubScreen = 0; +} + +// About sub-screen callback (#112) +static void aboutBackCb(lv_event_t* e) { + (void)e; + settingsSubScreen = 0; +} + +// Factory Reset sub-screen callbacks (#112) +static void resetBackCb(lv_event_t* e) { + (void)e; + settingsSubScreen = 0; +} + +static void resetBtnCb(lv_event_t* e) { + (void)e; + factoryReset(); + // Sync all LVGL widgets to restored defaults + if (cfgTimeToggle) fcToggleSetValue(cfgTimeToggle, use12Hour ? 0 : 1); + if (cfgTempToggle) fcToggleSetValue(cfgTempToggle, useFahrenheit ? 0 : 1); + if (cfgDistToggle) fcToggleSetValue(cfgDistToggle, useMetricUnits ? 1 : 0); + if (cfgTzDropdown) fcDropdownSetValue(cfgTzDropdown, tzSelectedIndex, tzDisplayName); + if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); + if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); + if (dispTftDropdown) { + int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); + fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); + } + if (dispOledDropdown) { + int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); + fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); + } + settingsSubScreen = 0; + logPrintln("[SETTINGS/LVGL] Factory reset executed"); +} + +// Update settings sub-screen visibility +void updateSettingsData() { + if (!settingsScr) return; + + // Only toggle container visibility when the sub-screen CHANGES (#117) + // Hiding+re-showing the active container every 500ms cancels LVGL's + // press tracking on child buttons, preventing CLICKED events. + static int prevSubScreen = -1; // force first-run update + if (settingsSubScreen != prevSubScreen) { + // Hide all containers + if (settingsMenuCtr) lv_obj_add_flag(settingsMenuCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsConfigCtr) lv_obj_add_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsDisplayCtr) lv_obj_add_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsCalCtr) lv_obj_add_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsDiagsCtr) lv_obj_add_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsAboutCtr) lv_obj_add_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); + if (settingsResetCtr) lv_obj_add_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); + + // Show the active sub-screen container and reset scroll position (#101) + switch (settingsSubScreen) { + case 0: if (settingsMenuCtr) lv_obj_clear_flag(settingsMenuCtr, LV_OBJ_FLAG_HIDDEN); break; + case 1: if (settingsConfigCtr) lv_obj_clear_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); break; + case 2: if (settingsDisplayCtr) lv_obj_clear_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); break; + case 3: if (settingsCalCtr) lv_obj_clear_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); break; + case 4: if (settingsDiagsCtr) lv_obj_clear_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); break; + case 5: if (settingsAboutCtr) lv_obj_clear_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); break; + case 6: if (settingsResetCtr) lv_obj_clear_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); break; + } + // Reset scroll to top when entering a sub-screen (#101) + if (settingsSubScreen >= 0 && settingsSubScreen < 7 && settingsScrollAreas[settingsSubScreen]) { + lv_obj_scroll_to_y(settingsScrollAreas[settingsSubScreen], 0, LV_ANIM_OFF); + } + prevSubScreen = settingsSubScreen; + logPrintf("[SETTINGS] Sub-screen changed to %d\n", settingsSubScreen); + } + + // --- Live data updates (run every 500ms regardless of visibility) --- + switch (settingsSubScreen) { + case 1: + if (settingsConfigCtr) { + // Live preview: time | temp | distance (#112) + if (cfgPreviewLabel) { + char prev[80]; + // Time preview using formatTimeStr (respects use12Hour) + struct tm timeinfo; + char timeBuf[16] = "--:--"; + if (getLocalTime(&timeinfo, 10)) + formatTimeStr(timeBuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, false); + // Temp preview + float tempC = shtAvailable ? shtData.temperature : envData.temperature; + float dispTemp = useFahrenheit ? (tempC * 9.0f / 5.0f + 32.0f) : tempC; + char tempBuf[16]; + snprintf(tempBuf, sizeof(tempBuf), "%.1f\xC2\xB0%s", dispTemp, useFahrenheit ? "F" : "C"); + // Distance preview + const char* distEx = useMetricUnits ? "1.0 km" : "0.6 mi"; + snprintf(prev, sizeof(prev), "%s | %s | %s", timeBuf, tempBuf, distEx); + lv_label_set_text(cfgPreviewLabel, prev); + } + } + break; + case 2: + if (settingsDisplayCtr) { + // Keep brightness widgets in sync if changed externally + if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); + if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); + } + break; + case 3: + if (settingsCalCtr) { + if (magCalibrating) { + // === ACTIVE CALIBRATION STATE === + // Hide idle widgets + if (calStatusLabel) lv_obj_add_flag(calStatusLabel, LV_OBJ_FLAG_HIDDEN); + if (calOffsetsLabel) lv_obj_add_flag(calOffsetsLabel, LV_OBJ_FLAG_HIDDEN); + if (calStartBtn) lv_obj_add_flag(calStartBtn, LV_OBJ_FLAG_HIDDEN); + if (calIdleActBar) lv_obj_add_flag(calIdleActBar, LV_OBJ_FLAG_HIDDEN); + // Show active widgets + if (calArc) lv_obj_clear_flag(calArc, LV_OBJ_FLAG_HIDDEN); + if (calCountdownLabel) lv_obj_clear_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); + if (calInstructLabel) lv_obj_clear_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); + if (calMinMaxLabel) lv_obj_clear_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); + + unsigned long elapsed = millis() - magCalStartTime; + int pct = (int)((elapsed * 100UL) / MAG_CAL_DURATION_MS); + if (pct > 100) pct = 100; + int remaining = ((int)MAG_CAL_DURATION_MS - (int)elapsed) / 1000; + if (remaining < 0) remaining = 0; + + // Update arc progress + if (calArc) lv_arc_set_value(calArc, pct); + // Update countdown + if (calCountdownLabel) lv_label_set_text_fmt(calCountdownLabel, "%d", remaining); + // Update min/max + if (calMinMaxLabel) { + char mmBuf[128]; + snprintf(mmBuf, sizeof(mmBuf), + "X: %.1f to %.1f\nY: %.1f to %.1f\nZ: %.1f to %.1f", + magCalMinX < 99998 ? magCalMinX : 0.0f, magCalMaxX > -99998 ? magCalMaxX : 0.0f, + magCalMinY < 99998 ? magCalMinY : 0.0f, magCalMaxY > -99998 ? magCalMaxY : 0.0f, + magCalMinZ < 99998 ? magCalMinZ : 0.0f, magCalMaxZ > -99998 ? magCalMaxZ : 0.0f); + lv_label_set_text(calMinMaxLabel, mmBuf); + } + + // Check completion + if (elapsed >= MAG_CAL_DURATION_MS) { + // Compute hard-iron offsets + magOffsetX = (magCalMaxX + magCalMinX) / 2.0f; + magOffsetY = (magCalMaxY + magCalMinY) / 2.0f; + magOffsetZ = (magCalMaxZ + magCalMinZ) / 2.0f; + magCalibrated = true; + magCalibrating = false; + saveMagCal(); + logPrintf("[MAG/LVGL] Cal complete: X=%.2f Y=%.2f Z=%.2f\n", magOffsetX, magOffsetY, magOffsetZ); + // Show completion briefly — idle widgets will show on next frame + } + + } else { + // === IDLE STATE === + // Show idle widgets + if (calStatusLabel) lv_obj_clear_flag(calStatusLabel, LV_OBJ_FLAG_HIDDEN); + if (calOffsetsLabel) lv_obj_clear_flag(calOffsetsLabel, LV_OBJ_FLAG_HIDDEN); + if (calStartBtn) lv_obj_clear_flag(calStartBtn, LV_OBJ_FLAG_HIDDEN); + if (calIdleActBar) lv_obj_clear_flag(calIdleActBar, LV_OBJ_FLAG_HIDDEN); + + // Update start button state — magAvailable may have changed since build time (#112) + if (calStartBtn) { + if (magAvailable) { + lv_obj_clear_state(calStartBtn, LV_STATE_DISABLED); + lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x007D00), 0); + } else { + lv_obj_add_state(calStartBtn, LV_STATE_DISABLED); + lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x424242), 0); + } + } + + // Hide active widgets + if (calArc) lv_obj_add_flag(calArc, LV_OBJ_FLAG_HIDDEN); + if (calCountdownLabel) lv_obj_add_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); + if (calInstructLabel) lv_obj_add_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); + if (calMinMaxLabel) lv_obj_add_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); + + // Update status + if (calStatusLabel) { + if (magCalibrated) { + lv_label_set_text(calStatusLabel, "Status: Calibrated"); + lv_obj_set_style_text_color(calStatusLabel, FC_COLOR_VALUE, 0); // Green + } else { + lv_label_set_text(calStatusLabel, "Status: Not calibrated"); + lv_obj_set_style_text_color(calStatusLabel, FC_COLOR_DIM, 0); + } + } + // Update offsets + if (calOffsetsLabel) { + if (magCalibrated) { + char offBuf[64]; + snprintf(offBuf, sizeof(offBuf), "X: %.2f Y: %.2f Z: %.2f", magOffsetX, magOffsetY, magOffsetZ); + lv_label_set_text(calOffsetsLabel, offBuf); + lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_VALUE, 0); + } else { + lv_label_set_text(calOffsetsLabel, "Offsets: ---"); + lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_DIM, 0); + } + } + } + } + break; + case 4: + if (settingsDiagsCtr) { + char dBuf[80]; + + // [0] BSEC + snprintf(dBuf, sizeof(dBuf), "Load:%s Save:%s Acc:%s", + bsecStateLoaded ? "Y" : "N", bsecStateSaved ? "Y" : "N", + getIaqAccuracyText(envData.iaqAccuracy)); + lv_label_set_text(diagValueLabels[0], dBuf); + lv_obj_set_style_text_color(diagValueLabels[0], bsecStateLoaded ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); + + // [1] Weather + snprintf(dBuf, sizeof(dBuf), "Mem:%d Files:%d Tot:%d", + weatherHistoryCount, weatherLogFileCount, weatherLogEntryCount); + lv_label_set_text(diagValueLabels[1], dBuf); + + // [2] Heap + snprintf(dBuf, sizeof(dBuf), "%luK / %luK", + (unsigned long)(ESP.getFreeHeap() / 1024), (unsigned long)(ESP.getHeapSize() / 1024)); + lv_label_set_text(diagValueLabels[2], dBuf); + + // [3] PSRAM + if (psramFound()) { + snprintf(dBuf, sizeof(dBuf), "%luK / %luK", + (unsigned long)(ESP.getFreePsram() / 1024), (unsigned long)(ESP.getPsramSize() / 1024)); + } else { + snprintf(dBuf, sizeof(dBuf), "Not available"); + } + lv_label_set_text(diagValueLabels[3], dBuf); + lv_obj_set_style_text_color(diagValueLabels[3], psramFound() ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); + + // [4] Sensors + snprintf(dBuf, sizeof(dBuf), "BME:%s SHT:%s IMU:%s Bat:%s FRAM:%s CTP:%s", + bmeAvailable ? "Y" : "N", shtAvailable ? "Y" : "N", + imuAvailable ? "Y" : "N", batteryAvailable ? "Y" : "N", + framAvailable ? "Y" : "N", touchAvailable ? "Y" : "N"); + lv_label_set_text(diagValueLabels[4], dBuf); + + // [5] Temps + if (shtAvailable && bmeAvailable) { + float shtT = useFahrenheit ? (shtData.temperature * 9.0f / 5.0f + 32.0f) : shtData.temperature; + float bmeT = useFahrenheit ? (envData.temperature * 9.0f / 5.0f + 32.0f) : envData.temperature; + snprintf(dBuf, sizeof(dBuf), "SHT:%.1f%s BME:%.1f%s (%+.1f)", + shtT, useFahrenheit ? "F" : "C", bmeT, useFahrenheit ? "F" : "C", shtT - bmeT); + } else if (shtAvailable) { + float t = useFahrenheit ? (shtData.temperature * 9.0f / 5.0f + 32.0f) : shtData.temperature; + snprintf(dBuf, sizeof(dBuf), "SHT:%.1f%s BME:N/A", t, useFahrenheit ? "F" : "C"); + } else if (bmeAvailable) { + float t = useFahrenheit ? (envData.temperature * 9.0f / 5.0f + 32.0f) : envData.temperature; + snprintf(dBuf, sizeof(dBuf), "SHT:N/A BME:%.1f%s", t, useFahrenheit ? "F" : "C"); + } else { + snprintf(dBuf, sizeof(dBuf), "No sensors"); + } + lv_label_set_text(diagValueLabels[5], dBuf); + + // [6] GPS + if (gpsHadFirstFix) { + snprintf(dBuf, sizeof(dBuf), "Fix in %lus", gpsFirstFixTime / 1000); + lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_VALUE, 0); + } else if (gpsHadFirstReceive) { + unsigned long elapsed = millis() / 1000; + snprintf(dBuf, sizeof(dBuf), "Acquiring (%lum %lus)", elapsed / 60, elapsed % 60); + lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_WARN, 0); + } else { + snprintf(dBuf, sizeof(dBuf), "No data"); + lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_DIM, 0); + } + lv_label_set_text(diagValueLabels[6], dBuf); + + // [7] MagCal + if (magCalibrated) { + snprintf(dBuf, sizeof(dBuf), "%.1f, %.1f, %.1f", magOffsetX, magOffsetY, magOffsetZ); + lv_obj_set_style_text_color(diagValueLabels[7], FC_COLOR_VALUE, 0); + } else { + snprintf(dBuf, sizeof(dBuf), "None"); + lv_obj_set_style_text_color(diagValueLabels[7], FC_COLOR_DIM, 0); + } + lv_label_set_text(diagValueLabels[7], dBuf); + + // [8] Storage + if (sdHealth.available) { + unsigned long ageMin = (millis() - sdHealth.lastSuccess) / 60000; + if (sdHealth.errorCount == 0) + snprintf(dBuf, sizeof(dBuf), "SD:OK %lum OLED:%s", ageMin, oledAvailable ? "Y" : "N"); + else { + snprintf(dBuf, sizeof(dBuf), "SD:WARN E:%d R:%d OLED:%s", + sdHealth.errorCount, sdHealth.reInitCount, oledAvailable ? "Y" : "N"); + lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_WARN, 0); + } + } else { + snprintf(dBuf, sizeof(dBuf), "SD:FAIL E:%d R:%d OLED:%s", + sdHealth.errorCount, sdHealth.reInitCount, oledAvailable ? "Y" : "N"); + lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_ERROR, 0); + } + if (sdHealth.available && sdHealth.errorCount == 0) + lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_VALUE, 0); + lv_label_set_text(diagValueLabels[8], dBuf); + + // [9] Web URL + if (wifiConnected) { + snprintf(dBuf, sizeof(dBuf), "http://fieldcompass.local/"); + lv_obj_set_style_text_color(diagValueLabels[9], FC_COLOR_DIM, 0); + } else { + snprintf(dBuf, sizeof(dBuf), "Not connected"); + lv_obj_set_style_text_color(diagValueLabels[9], FC_COLOR_DIM, 0); + } + lv_label_set_text(diagValueLabels[9], dBuf); + + // [10] Touch — last press coordinates + count (for debugging #117) + if (lastTouchX >= 0) { + snprintf(dBuf, sizeof(dBuf), "(%ld,%ld) #%lu", + lastTouchX, lastTouchY, (unsigned long)touchPressCount); + } else { + snprintf(dBuf, sizeof(dBuf), "No press yet"); + } + lv_label_set_text(diagValueLabels[10], dBuf); + lv_obj_set_style_text_color(diagValueLabels[10], + lastTouchX >= 0 ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); + } + break; + case 5: + if (settingsAboutCtr) { + char aBuf[64]; + + // [0] Version — static, already set in build + + // [1] Uptime + { + unsigned long uptimeSec = millis() / 1000; + int days = uptimeSec / 86400; + int hrs = (uptimeSec % 86400) / 3600; + int mins = (uptimeSec % 3600) / 60; + int secs = uptimeSec % 60; + if (days > 0) snprintf(aBuf, sizeof(aBuf), "%dd %02d:%02d:%02d", days, hrs, mins, secs); + else snprintf(aBuf, sizeof(aBuf), "%02d:%02d:%02d", hrs, mins, secs); + lv_label_set_text(aboutValueLabels[1], aBuf); + } + + // [2] Heap + snprintf(aBuf, sizeof(aBuf), "%lu / %lu KB", + (unsigned long)(ESP.getFreeHeap() / 1024), (unsigned long)(ESP.getHeapSize() / 1024)); + lv_label_set_text(aboutValueLabels[2], aBuf); + + // [3] PSRAM + snprintf(aBuf, sizeof(aBuf), "%lu / %lu KB", + (unsigned long)(ESP.getFreePsram() / 1024), (unsigned long)(ESP.getPsramSize() / 1024)); + lv_label_set_text(aboutValueLabels[3], aBuf); + + // [4] Battery + { + bool battConn = batteryAvailable && isBatteryConnected(); + if (battConn) { + float pct = battery.cellPercent(); + float v = battery.cellVoltage(); + snprintf(aBuf, sizeof(aBuf), "%.0f%% (%.2fV)", pct, v); + } else if (batteryAvailable) { + snprintf(aBuf, sizeof(aBuf), "USB Only"); + } else { + snprintf(aBuf, sizeof(aBuf), "N/A"); + } + lv_label_set_text(aboutValueLabels[4], aBuf); + } + + // [5] WiFi + { + bool wConn = (WiFi.status() == WL_CONNECTED); + if (wConn) { + snprintf(aBuf, sizeof(aBuf), "%s %s", + WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); + } else { + snprintf(aBuf, sizeof(aBuf), "Disconnected"); + } + lv_label_set_text(aboutValueLabels[5], aBuf); + } + } + break; + } +} + +void buildSettingsScreen() { + // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() + settingsScr = lv_obj_create(NULL); + lv_obj_set_size(settingsScr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsScr, FC_COLOR_BG, 0); + lv_obj_set_style_border_width(settingsScr, 0, 0); + lv_obj_set_style_radius(settingsScr, 0, 0); + lv_obj_set_style_pad_all(settingsScr, 0, 0); + lv_obj_clear_flag(settingsScr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + + // Menu container (sub-screen 0) + settingsMenuCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsMenuCtr); + lv_obj_set_size(settingsMenuCtr, SCREEN_W, SCREEN_H); + lv_obj_set_pos(settingsMenuCtr, 0, 0); + lv_obj_set_style_bg_color(settingsMenuCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsMenuCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsMenuCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + + // Header (fixed at top) + fcHeaderCreate(settingsMenuCtr, "SETTINGS"); + + // Scroll area (#101) + lv_obj_t* menuScroll = fcSettingsScrollCreate(settingsMenuCtr, 0); + + // Flex container for menu buttons (inside scroll area) + lv_obj_t* menuList = lv_obj_create(menuScroll); + lv_obj_remove_style_all(menuList); + lv_obj_set_size(menuList, 460, LV_SIZE_CONTENT); + lv_obj_set_pos(menuList, 10, 5); + lv_obj_set_style_pad_row(menuList, 1, 0); + lv_obj_set_flex_flow(menuList, LV_FLEX_FLOW_COLUMN); + lv_obj_clear_flag(menuList, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + + // 6 menu buttons — 38px tall with NO ext_click_area (#117) + // Root cause: ext_click_area on adjacent buttons creates OVERLAPPING hit + // zones (16px overlap with 4px gap + 10px ext), causing LVGL to pick the + // lower button. Fix: tall buttons, minimal gap, zero ext_click_area. + // Math: 6×38 + 5×1 = 233px fits in 235px container + for (int i = 0; i < SETTINGS_MENU_COUNT; i++) { + lv_obj_t* btn = lv_button_create(menuList); + lv_obj_set_size(btn, 440, 38); + lv_obj_set_style_bg_color(btn, lv_color_hex(0x424242), 0); + lv_obj_set_style_radius(btn, 6, 0); + // NO ext_click_area — prevents overlap with adjacent buttons + + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, settingsMenuItems[i]); + lv_obj_set_style_text_font(lbl, FC_FONT_MD, 0); + lv_obj_set_style_text_color(lbl, FC_COLOR_TEXT, 0); + lv_obj_center(lbl); + + lv_obj_set_user_data(btn, (void*)(intptr_t)(i + 1)); + lv_obj_add_event_cb(btn, settingsMenuBtnCb, LV_EVENT_CLICKED, NULL); + settingsMenuBtns[i] = btn; + } + + // Action bar with Back button only + lv_obj_t* actBar = fcActionBarCreate(settingsMenuCtr, true, false); + lv_obj_t* backBtn = lv_obj_get_child(actBar, 0); + lv_obj_add_event_cb(backBtn, settingsBackToScreenCb, LV_EVENT_CLICKED, NULL); + + // --- Sub-screen 1: Configuration (#112) --- + settingsConfigCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsConfigCtr); + lv_obj_set_size(settingsConfigCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsConfigCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsConfigCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsConfigCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsConfigCtr, "CONFIGURATION"); + + // Scroll area (#101) + lv_obj_t* cfgScroll = fcSettingsScrollCreate(settingsConfigCtr, 1); + + // Timezone dropdown — click on ENTIRE row opens picker (#117/#119) + cfgTzDropdown = fcDropdownCreate(cfgScroll, 15, "Time Zone", tzDisplayName); + lv_obj_add_event_cb(cfgTzDropdown, cfgTzDropdownCb, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(cfgTzDropdown, cfgTzSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); + // Time format toggle: 0=12Hour(left), 1=24Hour(right) + cfgTimeToggle = fcToggleCreate(cfgScroll, 65, "Time", "12 Hour", "24 Hour", use12Hour ? 0 : 1); + lv_obj_add_event_cb(cfgTimeToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); + + // Temperature unit toggle: 0=degF(left), 1=degC(right) + cfgTempToggle = fcToggleCreate(cfgScroll, 115, "Temp", "\xC2\xB0""F", "\xC2\xB0""C", useFahrenheit ? 0 : 1); + lv_obj_add_event_cb(cfgTempToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); + + // Distance unit toggle: 0=Imperial(left), 1=Metric(right) + cfgDistToggle = fcToggleCreate(cfgScroll, 165, "Distance", "Imperial", "Metric", useMetricUnits ? 1 : 0); + lv_obj_add_event_cb(cfgDistToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); + + // Live preview label + cfgPreviewLabel = lv_label_create(cfgScroll); + lv_obj_set_pos(cfgPreviewLabel, 20, 205); + lv_obj_set_style_text_font(cfgPreviewLabel, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(cfgPreviewLabel, lv_color_hex(0x808080), 0); + lv_label_set_text(cfgPreviewLabel, ""); + + // Action bar with Back and OK + lv_obj_t* cfgActBar = fcActionBarCreate(settingsConfigCtr, true, true); + lv_obj_t* cfgBack = lv_obj_get_child(cfgActBar, 0); + lv_obj_t* cfgOK = lv_obj_get_child(cfgActBar, 1); + lv_obj_add_event_cb(cfgBack, cfgBackCb, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(cfgOK, cfgOKCb, LV_EVENT_CLICKED, NULL); + + // --- Sub-screen 2: Display (#112) --- + settingsDisplayCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsDisplayCtr); + lv_obj_set_size(settingsDisplayCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsDisplayCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsDisplayCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsDisplayCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsDisplayCtr, "DISPLAY"); + + // Scroll area (#101) + lv_obj_t* dispScroll = fcSettingsScrollCreate(settingsDisplayCtr, 2); + + // Brightness label + lv_obj_t* brightLbl = lv_label_create(dispScroll); + lv_label_set_text(brightLbl, "Brightness"); + lv_obj_set_style_text_color(brightLbl, FC_COLOR_DIM, 0); + lv_obj_set_style_text_font(brightLbl, FC_FONT_SM, 0); + lv_obj_set_pos(brightLbl, 20, 23); + + // Brightness slider: range 25-255, initial = tftBrightness + dispBrightnessSlider = lv_slider_create(dispScroll); + lv_obj_set_size(dispBrightnessSlider, 250, 30); + lv_obj_set_pos(dispBrightnessSlider, 140, 17); + lv_slider_set_range(dispBrightnessSlider, 25, 255); + lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); + // Style: green indicator, dark gray track, white knob + lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0x2A2A2A), 0); // track bg + lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0x007D00), LV_PART_INDICATOR); // green fill + lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); // white knob + lv_obj_set_style_pad_all(dispBrightnessSlider, 8, LV_PART_KNOB); // bigger knob touch target + lv_obj_add_event_cb(dispBrightnessSlider, dispBrightnessChangedCb, LV_EVENT_VALUE_CHANGED, NULL); + + // Brightness numeric readout (to the right of slider) + dispBrightnessLabel = lv_label_create(dispScroll); + lv_obj_set_pos(dispBrightnessLabel, 375, 23); + lv_obj_set_style_text_color(dispBrightnessLabel, FC_COLOR_VALUE, 0); + lv_obj_set_style_text_font(dispBrightnessLabel, FC_FONT_SM, 0); + lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); + + // TFT Sleep timeout dropdown — click on entire row (#117) + int tftIdx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); + dispTftDropdown = fcDropdownCreate(dispScroll, 75, "TFT Sleep", tftTimeoutLabels[tftIdx]); + lv_obj_add_event_cb(dispTftDropdown, dispTftDropdownCb, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(dispTftDropdown, dispTftSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); + + // OLED Sleep timeout dropdown — click on entire row (#117) + int oledIdx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); + dispOledDropdown = fcDropdownCreate(dispScroll, 125, "OLED Sleep", oledTimeoutLabels[oledIdx]); + lv_obj_add_event_cb(dispOledDropdown, dispOledDropdownCb, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(dispOledDropdown, dispOledSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); + + // Action bar with Back + OK + lv_obj_t* dispActBar = fcActionBarCreate(settingsDisplayCtr, true, true); + lv_obj_t* dispBack = lv_obj_get_child(dispActBar, 0); + lv_obj_t* dispOK = lv_obj_get_child(dispActBar, 1); + lv_obj_add_event_cb(dispBack, dispBackCb, LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(dispOK, dispOKCb, LV_EVENT_CLICKED, NULL); + + // --- Sub-screen 3: Compass Calibration (#112) --- + settingsCalCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsCalCtr); + lv_obj_set_size(settingsCalCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsCalCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsCalCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsCalCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsCalCtr, "COMPASS CAL"); + + // Scroll area (#101) + lv_obj_t* calScroll = fcSettingsScrollCreate(settingsCalCtr, 3); + + // === Idle state widgets === + + // Status label + calStatusLabel = lv_label_create(calScroll); + lv_obj_set_pos(calStatusLabel, 20, 20); + lv_obj_set_style_text_font(calStatusLabel, FC_FONT_MD, 0); + lv_label_set_text(calStatusLabel, "Status: ---"); + + // Offsets label + calOffsetsLabel = lv_label_create(calScroll); + lv_obj_set_pos(calOffsetsLabel, 20, 50); + lv_obj_set_style_text_font(calOffsetsLabel, FC_FONT_SM, 0); + lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_DIM, 0); + lv_label_set_text(calOffsetsLabel, "Offsets: ---"); + + // Start Calibration button + calStartBtn = lv_button_create(calScroll); + lv_obj_set_size(calStartBtn, 220, 40); + lv_obj_set_pos(calStartBtn, 130, 100); + lv_obj_set_style_radius(calStartBtn, 6, 0); + if (magAvailable) { + lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x007D00), 0); // Green + } else { + lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x424242), 0); // Gray disabled + lv_obj_add_state(calStartBtn, LV_STATE_DISABLED); + } + lv_obj_add_event_cb(calStartBtn, calStartBtnCb, LV_EVENT_CLICKED, NULL); + lv_obj_t* calBtnLbl = lv_label_create(calStartBtn); + lv_label_set_text(calBtnLbl, "Start Calibration"); + lv_obj_set_style_text_font(calBtnLbl, FC_FONT_MD, 0); + lv_obj_set_style_text_color(calBtnLbl, FC_COLOR_TEXT, 0); + lv_obj_center(calBtnLbl); + + // Idle action bar with Back only + calIdleActBar = fcActionBarCreate(settingsCalCtr, true, false); + lv_obj_t* calBack = lv_obj_get_child(calIdleActBar, 0); + lv_obj_add_event_cb(calBack, calBackCb, LV_EVENT_CLICKED, NULL); + + // === Active calibration widgets (hidden initially, inside scroll area) === + + // Progress arc + calArc = lv_arc_create(calScroll); + lv_obj_set_size(calArc, 140, 140); + lv_obj_set_pos(calArc, 170, 25); + lv_arc_set_range(calArc, 0, 100); + lv_arc_set_value(calArc, 0); + lv_arc_set_bg_angles(calArc, 0, 360); + lv_obj_remove_style(calArc, NULL, LV_PART_KNOB); // Hide knob + lv_obj_clear_flag(calArc, LV_OBJ_FLAG_CLICKABLE); // Not interactive + lv_obj_set_style_arc_color(calArc, lv_color_hex(0x2A2A2A), LV_PART_MAIN); // Background arc + lv_obj_set_style_arc_color(calArc, lv_color_hex(0x007D00), LV_PART_INDICATOR); // Green progress + lv_obj_set_style_arc_width(calArc, 12, LV_PART_MAIN); + lv_obj_set_style_arc_width(calArc, 12, LV_PART_INDICATOR); + lv_obj_add_flag(calArc, LV_OBJ_FLAG_HIDDEN); + + // Countdown label (centered in arc) + calCountdownLabel = lv_label_create(calScroll); + lv_obj_set_pos(calCountdownLabel, 225, 75); // Centered in arc area + lv_obj_set_style_text_font(calCountdownLabel, FC_FONT_HERO, 0); // 32px + lv_obj_set_style_text_color(calCountdownLabel, FC_COLOR_TEXT, 0); + lv_label_set_text(calCountdownLabel, "15"); + lv_obj_add_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); + + // Instruction label + calInstructLabel = lv_label_create(calScroll); + lv_obj_set_pos(calInstructLabel, 80, 180); + lv_obj_set_style_text_font(calInstructLabel, FC_FONT_MD, 0); + lv_obj_set_style_text_color(calInstructLabel, FC_COLOR_WARN, 0); // Orange + lv_label_set_text(calInstructLabel, "Rotate device slowly 360\xC2\xB0"); + lv_obj_add_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); + + // Min/max label + calMinMaxLabel = lv_label_create(calScroll); + lv_obj_set_pos(calMinMaxLabel, 20, 215); + lv_obj_set_style_text_font(calMinMaxLabel, FC_FONT_XS, 0); // 14px + lv_obj_set_style_text_color(calMinMaxLabel, FC_COLOR_DIM, 0); + lv_label_set_text(calMinMaxLabel, ""); + lv_obj_add_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); + + // --- Sub-screen 4: Diagnostics (#112) --- + settingsDiagsCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsDiagsCtr); + lv_obj_set_size(settingsDiagsCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsDiagsCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsDiagsCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsDiagsCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsDiagsCtr, "DIAGNOSTICS"); + + // Scroll area (#101) — Diagnostics has 11 rows at 21px = 231px, close to 240px limit + lv_obj_t* diagsScroll = fcSettingsScrollCreate(settingsDiagsCtr, 4); + + // 11 label-value rows + static const char* diagLabels[11] = { + "BSEC:", "Weather:", "Heap:", "PSRAM:", "Sensors:", + "Temps:", "GPS:", "MagCal:", "Storage:", "Web:", "Touch:" + }; + + int diagY = 8; + int diagLineH = 21; + for (int i = 0; i < 11; i++) { + // Cyan label + lv_obj_t* lbl = lv_label_create(diagsScroll); + lv_label_set_text(lbl, diagLabels[i]); + lv_obj_set_pos(lbl, 10, diagY + i * diagLineH); + lv_obj_set_style_text_font(lbl, FC_FONT_XS, 0); + lv_obj_set_style_text_color(lbl, FC_COLOR_HEADER, 0); + + // Value label (updated each frame) + diagValueLabels[i] = lv_label_create(diagsScroll); + lv_label_set_text(diagValueLabels[i], "---"); + lv_obj_set_pos(diagValueLabels[i], 70, diagY + i * diagLineH); + lv_obj_set_style_text_font(diagValueLabels[i], FC_FONT_XS, 0); + lv_obj_set_style_text_color(diagValueLabels[i], FC_COLOR_VALUE, 0); + } + + // Action bar with Back only + lv_obj_t* diagsActBar = fcActionBarCreate(settingsDiagsCtr, true, false); + lv_obj_t* diagsBack = lv_obj_get_child(diagsActBar, 0); + lv_obj_add_event_cb(diagsBack, diagsBackCb, LV_EVENT_CLICKED, NULL); + + // --- Sub-screen 5: About (#112) --- + settingsAboutCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsAboutCtr); + lv_obj_set_size(settingsAboutCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsAboutCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsAboutCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsAboutCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsAboutCtr, "ABOUT"); + + // Scroll area (#101) + lv_obj_t* aboutScroll = fcSettingsScrollCreate(settingsAboutCtr, 5); + + // 6 label-value rows + static const char* aboutLabels[6] = { + "Version:", "Uptime:", "Heap:", "PSRAM:", "Battery:", "WiFi:" + }; + + int aboutY = 20; + int aboutLineH = 30; + for (int i = 0; i < 6; i++) { + lv_obj_t* lbl = lv_label_create(aboutScroll); + lv_label_set_text(lbl, aboutLabels[i]); + lv_obj_set_pos(lbl, 20, aboutY + i * aboutLineH); + lv_obj_set_style_text_font(lbl, FC_FONT_MD, 0); + lv_obj_set_style_text_color(lbl, FC_COLOR_DIM, 0); + + aboutValueLabels[i] = lv_label_create(aboutScroll); + lv_label_set_text(aboutValueLabels[i], "---"); + lv_obj_set_pos(aboutValueLabels[i], 160, aboutY + i * aboutLineH); + lv_obj_set_style_text_font(aboutValueLabels[i], FC_FONT_MD, 0); + lv_obj_set_style_text_color(aboutValueLabels[i], FC_COLOR_VALUE, 0); + } + + // Set version (static, never changes) + lv_label_set_text(aboutValueLabels[0], FW_VERSION); + + // Action bar with Back only + lv_obj_t* aboutActBar = fcActionBarCreate(settingsAboutCtr, true, false); + lv_obj_t* aboutBack = lv_obj_get_child(aboutActBar, 0); + lv_obj_add_event_cb(aboutBack, aboutBackCb, LV_EVENT_CLICKED, NULL); + + // --- Sub-screen 6: Factory Reset (#112) --- + settingsResetCtr = lv_obj_create(settingsScr); + lv_obj_remove_style_all(settingsResetCtr); + lv_obj_set_size(settingsResetCtr, SCREEN_W, SCREEN_H); + lv_obj_set_style_bg_color(settingsResetCtr, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(settingsResetCtr, LV_OPA_COVER, 0); + lv_obj_clear_flag(settingsResetCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); + lv_obj_add_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); + + fcHeaderCreate(settingsResetCtr, "FACTORY RESET"); + + // Scroll area (#101) + lv_obj_t* resetScroll = fcSettingsScrollCreate(settingsResetCtr, 6); + + // Warning text (orange) + lv_obj_t* resetWarn = lv_label_create(resetScroll); + lv_label_set_text(resetWarn, "Reset all settings to\nfactory defaults?"); + lv_obj_set_pos(resetWarn, 20, 60); + lv_obj_set_style_text_font(resetWarn, FC_FONT_LG, 0); // 20px + lv_obj_set_style_text_color(resetWarn, FC_COLOR_WARN, 0); // Orange + + // Info text (dim) + lv_obj_t* resetInfo = lv_label_create(resetScroll); + lv_label_set_text(resetInfo, "Compass calibration will\nbe preserved."); + lv_obj_set_pos(resetInfo, 20, 125); + lv_obj_set_style_text_font(resetInfo, FC_FONT_SM, 0); // 16px + lv_obj_set_style_text_color(resetInfo, FC_COLOR_DIM, 0); + + // Action bar with Back only + lv_obj_t* resetActBar = fcActionBarCreate(settingsResetCtr, true, false); + lv_obj_t* resetBack = lv_obj_get_child(resetActBar, 0); + lv_obj_add_event_cb(resetBack, resetBackCb, LV_EVENT_CLICKED, NULL); + + // Red "Reset" button at OK button position + lv_obj_t* resetBtn = lv_button_create(settingsResetCtr); + lv_obj_set_size(resetBtn, 100, 40); + lv_obj_set_pos(resetBtn, 360, 275); + lv_obj_set_style_bg_color(resetBtn, lv_color_hex(0xCC0000), 0); // Red + lv_obj_set_style_radius(resetBtn, 8, 0); + lv_obj_add_event_cb(resetBtn, resetBtnCb, LV_EVENT_CLICKED, NULL); + lv_obj_t* resetBtnLbl = lv_label_create(resetBtn); + lv_label_set_text(resetBtnLbl, "Reset"); + lv_obj_set_style_text_font(resetBtnLbl, FC_FONT_MD, 0); + lv_obj_set_style_text_color(resetBtnLbl, FC_COLOR_TEXT, 0); + lv_obj_center(resetBtnLbl); +} diff --git a/src/screen_settings.h b/src/screen_settings.h new file mode 100644 index 0000000..29d298a --- /dev/null +++ b/src/screen_settings.h @@ -0,0 +1,12 @@ +#pragma once +// screen_settings.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +#define SETTINGS_MENU_COUNT 6 // Configuration, Display, Compass Cal, Diagnostics, About, Factory Reset (#104) +#define SETTINGS_SCROLL_STEP 50 // Pixels per A/B button press + +extern lv_obj_t* settingsScr; +extern lv_obj_t* settingsScrollAreas[7]; + +void updateSettingsData(); +void buildSettingsScreen(); diff --git a/src/screen_telemetry.cpp b/src/screen_telemetry.cpp new file mode 100644 index 0000000..d2bd276 --- /dev/null +++ b/src/screen_telemetry.cpp @@ -0,0 +1,320 @@ +// screen_telemetry.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "screen_telemetry.h" +#include "fc_theme.h" +#include "ui_widgets.h" +#include "navigation.h" +#include "ui_state.h" +#include "gps.h" +#include "imu.h" +#include "settings.h" +#include "geo.h" +#include "logging.h" + +lv_obj_t* telemetryScr = NULL; +lv_obj_t* telHeader = NULL; +static lv_obj_t* telNavBar = NULL; +static lv_obj_t* telLblGpsSection = NULL; +static lv_obj_t* telLblImuSection = NULL; +static lv_obj_t* telLblLatLabel = NULL; +static lv_obj_t* telLblLatValue = NULL; +static lv_obj_t* telLblLonLabel = NULL; +static lv_obj_t* telLblLonValue = NULL; +static lv_obj_t* telLblAltLabel = NULL; +static lv_obj_t* telLblAltValue = NULL; +static lv_obj_t* telLblSpdLabel = NULL; +static lv_obj_t* telLblSpdValue = NULL; +static lv_obj_t* telLblSatLabel = NULL; +static lv_obj_t* telLblSatValue = NULL; +static lv_obj_t* telLblHdopLabel = NULL; +static lv_obj_t* telLblHdopValue = NULL; +static lv_obj_t* telLblStatusLabel = NULL; +static lv_obj_t* telLblStatusValue = NULL; +static lv_obj_t* telLblGpsAcquiring = NULL; +static lv_obj_t* telLblGpsElapsed = NULL; +static lv_obj_t* telLblGpsSkyHint = NULL; +static lv_obj_t* telLblGpsSatCount = NULL; +static lv_obj_t* telLblGpsNoData = NULL; +static lv_obj_t* telLblGpsCheckConn = NULL; +static lv_obj_t* telDivider = NULL; +static lv_obj_t* telLblHdgLabel = NULL; +static lv_obj_t* telLblHdgValue = NULL; +static lv_obj_t* telLblRollLabel = NULL; +static lv_obj_t* telLblRollValue = NULL; +static lv_obj_t* telLblPitchLabel = NULL; +static lv_obj_t* telLblPitchValue = NULL; +static lv_obj_t* telLblAccelLabel = NULL; +static lv_obj_t* telLblAccelValue = NULL; +static lv_obj_t* telLblNoImu = NULL; + +void buildTelemetryScreen() { + // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() + telemetryScr = lv_obj_create(NULL); + lv_obj_set_size(telemetryScr, 480, 320); + lv_obj_set_style_bg_color(telemetryScr, FC_COLOR_BG, 0); + lv_obj_set_style_border_width(telemetryScr, 0, 0); + lv_obj_set_style_radius(telemetryScr, 0, 0); + lv_obj_set_style_pad_all(telemetryScr, 0, 0); + lv_obj_clear_flag(telemetryScr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(telemetryScr, screenGestureCb, LV_EVENT_GESTURE, NULL); + + // Header + telHeader = fcHeaderCreate(telemetryScr, "TELEMETRY"); + + // Column geometry + int leftLabelX = 20; + int leftValueX = 110; + int rightLabelX = 250; + int rightValueX = 350; + int lineH = 28; + + // Helper lambda for label creation + auto makeLabel = [&](lv_obj_t** out, const char* text, int x, int yPos, + const lv_font_t* font, lv_color_t color) { + *out = lv_label_create(telemetryScr); + lv_label_set_text(*out, text); + lv_obj_set_pos(*out, x, yPos); + lv_obj_set_style_text_font(*out, font, 0); + lv_obj_set_style_text_color(*out, color, 0); + }; + + // === GPS Section === + telLblGpsSection = lv_label_create(telemetryScr); + lv_label_set_text(telLblGpsSection, "GPS"); + lv_obj_set_style_text_font(telLblGpsSection, FC_FONT_SM, 0); + lv_obj_set_style_text_color(telLblGpsSection, FC_COLOR_HEADER, 0); + lv_obj_set_width(telLblGpsSection, 480); + lv_obj_set_style_text_align(telLblGpsSection, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_pos(telLblGpsSection, 0, 36); + + // GPS data rows (y starts at 56) + int gy = 56; + + // Row 1: Lat / Lon + makeLabel(&telLblLatLabel, "Lat:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblLatValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + makeLabel(&telLblLonLabel, "Lon:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblLonValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + gy += lineH; + + // Row 2: Alt / Spd + makeLabel(&telLblAltLabel, "Alt:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblAltValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + makeLabel(&telLblSpdLabel, "Spd:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblSpdValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + gy += lineH; + + // Row 3: Sat / HDOP + makeLabel(&telLblSatLabel, "Sat:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblSatValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + makeLabel(&telLblHdopLabel, "HDOP:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblHdopValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + gy += lineH; + + // Row 4: Status (full width) + makeLabel(&telLblStatusLabel, "Status:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblStatusValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); + + // GPS acquiring state labels (hidden by default) + makeLabel(&telLblGpsAcquiring, "Acquiring fix...", 60, 70, FC_FONT_LG, FC_COLOR_WARN); + lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); + + makeLabel(&telLblGpsElapsed, "Elapsed: 0m 0s", 60, 100, FC_FONT_LG, FC_COLOR_DIM); + lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); + + makeLabel(&telLblGpsSkyHint, "Need clear sky view", 60, 130, FC_FONT_MD, FC_COLOR_DIM); + lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); + + makeLabel(&telLblGpsSatCount, "Sats: 0", 60, 147, FC_FONT_LG, FC_COLOR_VALUE); + lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); + + // GPS no-data state labels (hidden by default) + makeLabel(&telLblGpsNoData, "No GPS data", 80, 80, FC_FONT_LG, FC_COLOR_ERROR); + lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); + + makeLabel(&telLblGpsCheckConn, "Check connection", 60, 116, FC_FONT_LG, FC_COLOR_DIM); + lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); + + // === Divider === + telDivider = lv_obj_create(telemetryScr); + lv_obj_set_size(telDivider, 460, 1); + lv_obj_set_pos(telDivider, 10, 172); + lv_obj_set_style_bg_color(telDivider, FC_COLOR_DIM, 0); + lv_obj_set_style_bg_opa(telDivider, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(telDivider, 0, 0); + lv_obj_set_style_radius(telDivider, 0, 0); + lv_obj_set_style_pad_all(telDivider, 0, 0); + lv_obj_clear_flag(telDivider, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); + + // === IMU Section === + telLblImuSection = lv_label_create(telemetryScr); + lv_label_set_text(telLblImuSection, "IMU"); + lv_obj_set_style_text_font(telLblImuSection, FC_FONT_SM, 0); + lv_obj_set_style_text_color(telLblImuSection, FC_COLOR_HEADER, 0); + lv_obj_set_width(telLblImuSection, 480); + lv_obj_set_style_text_align(telLblImuSection, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_pos(telLblImuSection, 0, 178); + + int iy = 198; + + // Row 5: Heading / Roll + makeLabel(&telLblHdgLabel, "Hdg:", leftLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblHdgValue, "---", leftValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); + makeLabel(&telLblRollLabel, "Roll:", rightLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblRollValue, "---", rightValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); + iy += lineH; + + // Row 6: Pitch / Accel + makeLabel(&telLblPitchLabel, "Pitch:", leftLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblPitchValue, "---", leftValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); + makeLabel(&telLblAccelLabel, "Accel:", rightLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); + makeLabel(&telLblAccelValue, "---", rightValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); + + // IMU not available label (hidden by default) + makeLabel(&telLblNoImu, "IMU not available", 60, 210, FC_FONT_LG, FC_COLOR_ERROR); + lv_obj_add_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); + + // NavBar + telNavBar = fcNavBarCreate(telemetryScr, NUM_SCREENS, SCREEN_TELEMETRY); +} + +// Helper: show or hide GPS data row labels +static void telShowGpsDataRows(bool show) { + lv_obj_t* gpsLabels[] = { + telLblLatLabel, telLblLatValue, telLblLonLabel, telLblLonValue, + telLblAltLabel, telLblAltValue, telLblSpdLabel, telLblSpdValue, + telLblSatLabel, telLblSatValue, telLblHdopLabel, telLblHdopValue, + telLblStatusLabel, telLblStatusValue + }; + for (auto lbl : gpsLabels) { + if (show) + lv_obj_clear_flag(lbl, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); + } +} + +static void telShowImuDataRows(bool show) { + lv_obj_t* imuLabels[] = { + telLblHdgLabel, telLblHdgValue, telLblRollLabel, telLblRollValue, + telLblPitchLabel, telLblPitchValue, telLblAccelLabel, telLblAccelValue + }; + for (auto lbl : imuLabels) { + if (show) + lv_obj_clear_flag(lbl, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); + } +} + +void updateTelemetryData() { + if (!telemetryScr) return; + char buf[80]; + + fcNavBarSetActive(telNavBar, currentScreen); + + // === GPS Section === + if (gpsData.valid) { + // Show data rows, hide acquiring/error labels + telShowGpsDataRows(true); + lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); + + // Lat + snprintf(buf, sizeof(buf), "%.6f %c", fabs(gpsData.latitude), + gpsData.latitude >= 0 ? 'N' : 'S'); + lv_label_set_text(telLblLatValue, buf); + + // Lon + snprintf(buf, sizeof(buf), "%.6f %c", fabs(gpsData.longitude), + gpsData.longitude >= 0 ? 'E' : 'W'); + lv_label_set_text(telLblLonValue, buf); + + // Alt (respects useMetricUnits) + float alt = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; + snprintf(buf, sizeof(buf), "%.1f %s", alt, useMetricUnits ? "m" : "ft"); + lv_label_set_text(telLblAltValue, buf); + + // Speed + float speed = gpsData.speedKnots * (useMetricUnits ? 1.852 : 1.15078); + snprintf(buf, sizeof(buf), "%.1f %s", speed, useMetricUnits ? "km/h" : "mph"); + lv_label_set_text(telLblSpdValue, buf); + + // Satellites + snprintf(buf, sizeof(buf), "%d", gpsData.satellites); + lv_label_set_text(telLblSatValue, buf); + + // HDOP + snprintf(buf, sizeof(buf), "%.1f", gpsData.hdop); + lv_label_set_text(telLblHdopValue, buf); + + // Status + if (gpsHadFirstFix) + snprintf(buf, sizeof(buf), "Fix OK (TTFF %lus)", gpsFirstFixTime / 1000); + else + strcpy(buf, "Fix OK"); + lv_label_set_text(telLblStatusValue, buf); + + } else if (gpsData.receiving) { + // Acquiring — hide data rows, show acquiring labels + telShowGpsDataRows(false); + lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); + + lv_obj_clear_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); + + unsigned long elapsed; + if (gpsHadFirstFix && gpsSignalLostTime > 0) { + // Reacquiring after signal loss + elapsed = (millis() - gpsSignalLostTime) / 1000; + snprintf(buf, sizeof(buf), "Reacquire: %lum %lus", elapsed / 60, elapsed % 60); + } else { + // Never had fix — time since first NMEA data + elapsed = (millis() - gpsFirstReceiveTime) / 1000; + snprintf(buf, sizeof(buf), "Elapsed: %lum %lus", elapsed / 60, elapsed % 60); + } + lv_label_set_text(telLblGpsElapsed, buf); + + snprintf(buf, sizeof(buf), "Sats: %d", gpsData.satellites); + lv_label_set_text(telLblGpsSatCount, buf); + + } else { + // No GPS — hide data rows + acquiring, show error + telShowGpsDataRows(false); + lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); + + lv_obj_clear_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); + } + + // === IMU Section === + if (imuAvailable && magAvailable) { + telShowImuDataRows(true); + lv_obj_add_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); + + snprintf(buf, sizeof(buf), "%.0f %s", imuData.heading, getCardinal(imuData.heading)); + lv_label_set_text(telLblHdgValue, buf); + + snprintf(buf, sizeof(buf), "%.0f deg", imuData.roll); + lv_label_set_text(telLblRollValue, buf); + + snprintf(buf, sizeof(buf), "%.0f deg", imuData.pitch); + lv_label_set_text(telLblPitchValue, buf); + + snprintf(buf, sizeof(buf), "%.2f m/s2", imuData.accelMag); + lv_label_set_text(telLblAccelValue, buf); + + } else { + telShowImuDataRows(false); + lv_obj_clear_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); + } +} diff --git a/src/screen_telemetry.h b/src/screen_telemetry.h new file mode 100644 index 0000000..7b45649 --- /dev/null +++ b/src/screen_telemetry.h @@ -0,0 +1,9 @@ +#pragma once +// screen_telemetry.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include + +extern lv_obj_t* telemetryScr; +extern lv_obj_t* telHeader; + +void buildTelemetryScreen(); +void updateTelemetryData(); diff --git a/src/src.ino b/src/src.ino index bd0ed57..0911223 100644 --- a/src/src.ino +++ b/src/src.ino @@ -72,6 +72,12 @@ #include "web.h" #include "lvgl_port.h" #include "oled_screens.h" +#include "screen_compass.h" +#include "screen_env.h" +#include "screen_telemetry.h" +#include "screen_geocache.h" +#include "screen_settings.h" +#include "navigation.h" // ============== Configuration ============== @@ -114,17 +120,6 @@ // Legacy swipe/tap detection removed — LVGL gesture + click callbacks (#113) -// Settings screen state -#define SETTINGS_MENU_COUNT 6 // Configuration, Display, Compass Cal, Diagnostics, About, Factory Reset (#104) -static const char* settingsMenuItems[] = { - "Configuration", - "Display", - "Compass Cal", - "Diagnostics", - "About", - "Factory Reset" -}; - // ============== Global State ============== @@ -463,2948 +458,6 @@ void scanI2C() { logPrintf(" Total devices: %d\n\n", deviceCount); } -// ============== Field Compass LVGL Theme (#107) ============== -// ============== FC Widget Library (#108) ============== - -// Forward declarations for callbacks used in widgets (#113) -static void gearIconClickCb(lv_event_t* e); -static void gcNavListBtnCb(lv_event_t* e); // (#122) nav→list touch nav -static void gcListRowTapCb(lv_event_t* e); // (#122) tap row to select -static void gcFilterBtnCb(lv_event_t* e); // (#122) filter screen callbacks -static void gcFilterBackCb(lv_event_t* e); -static void gcFilterFoundCb(lv_event_t* e); -static void gcFilterSortCb(lv_event_t* e); -static void gcFilterDMinUpCb(lv_event_t* e); -static void gcFilterTMinUpCb(lv_event_t* e); -static void gcFilterDistCb(lv_event_t* e); -static void gcFilterResetCb(lv_event_t* e); -static void screenGestureCb(lv_event_t* e); - -// --- Header Bar: 30px cyan bar with title + gear icon --- -lv_obj_t* fcHeaderCreate(lv_obj_t* parent, const char* title) { - lv_obj_t* cont = lv_obj_create(parent); - lv_obj_remove_style_all(cont); - lv_obj_set_size(cont, SCREEN_W, 30); - lv_obj_set_pos(cont, 0, 0); - lv_obj_set_style_bg_color(cont, FC_COLOR_HEADER, 0); - lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0); - lv_obj_clear_flag(cont, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - - // Child [0]: title label — black on cyan - lv_obj_t* titleLbl = lv_label_create(cont); - lv_label_set_text(titleLbl, title); - lv_obj_set_style_text_color(titleLbl, FC_COLOR_BG, 0); - lv_obj_set_style_text_font(titleLbl, FC_FONT_SM, 0); - lv_obj_set_pos(titleLbl, 10, 7); - - // Child [1]: gear button — tappable settings icon - lv_obj_t* gearBtn = lv_button_create(cont); - lv_obj_remove_style_all(gearBtn); - lv_obj_set_size(gearBtn, 30, 30); - lv_obj_align(gearBtn, LV_ALIGN_RIGHT_MID, -5, 0); - lv_obj_set_style_bg_opa(gearBtn, LV_OPA_TRANSP, 0); - - lv_obj_t* gearLbl = lv_label_create(gearBtn); - lv_label_set_text(gearLbl, LV_SYMBOL_SETTINGS); - lv_obj_set_style_text_color(gearLbl, FC_COLOR_BG, 0); - lv_obj_set_style_text_font(gearLbl, FC_FONT_SM, 0); - lv_obj_center(gearLbl); - - // Gear icon click → Settings (#113) — callback defined in navigation section - lv_obj_add_event_cb(gearBtn, gearIconClickCb, LV_EVENT_CLICKED, NULL); - - // Child [2]: SD status indicator — hidden when healthy (#120) - lv_obj_t* sdLbl = lv_label_create(cont); - lv_label_set_text(sdLbl, LV_SYMBOL_SD_CARD); - lv_obj_set_style_text_font(sdLbl, FC_FONT_SM, 0); - lv_obj_set_style_text_color(sdLbl, lv_color_hex(0x808080), 0); // default gray - lv_obj_align(sdLbl, LV_ALIGN_RIGHT_MID, -38, 0); - // Starts visible — updateSDIndicators() sets color per state (#120) - - return cont; -} - -// --- Toggle: two-option selector with green=selected --- - -// --- List Picker: modal scrollable selection overlay --- - -// Track active list picker overlay — prevents stacking (#119) -static lv_obj_t* fcListPickerActiveOverlay = NULL; - -// Internal: list item click handler — select and close -static void fcListPickerItemCb(lv_event_t* e) { - lv_obj_t* itemBtn = (lv_obj_t*)lv_event_get_target(e); - int idx = (int)(intptr_t)lv_obj_get_user_data(itemBtn); - - // Navigate up: itemBtn -> list -> box -> overlay - lv_obj_t* list = lv_obj_get_parent(itemBtn); - lv_obj_t* box = lv_obj_get_parent(list); - lv_obj_t* overlay = lv_obj_get_parent(box); - - // Get caller stored in overlay user_data - lv_obj_t* caller = (lv_obj_t*)lv_obj_get_user_data(overlay); - - // Notify caller with selected index - if (caller) { - lv_obj_send_event(caller, LV_EVENT_VALUE_CHANGED, (void*)(intptr_t)idx); - } - - // Close: async-delete overlay to avoid use-after-free during event callback (#112) - fcListPickerActiveOverlay = NULL; // Clear tracker before delete (#119) - lv_obj_delete_async(overlay); -} - -lv_obj_t* fcListPickerOpen(const char* title, const char** items, - int count, int selectedIdx, lv_obj_t* caller) { - // Guard: destroy any existing overlay before creating a new one (#119) - if (fcListPickerActiveOverlay != NULL) { - logPrintln("[LVGL/MEM] Closing stale list picker overlay before opening new one"); - lv_obj_delete(fcListPickerActiveOverlay); - fcListPickerActiveOverlay = NULL; - } - - // Safety check: ensure enough LVGL heap for overlay (~count*2+4 objects) (#119) - lv_mem_monitor_t mon; - lv_mem_monitor(&mon); - logPrintf("[LVGL/MEM] ListPicker open '%s' (%d items): free=%lu frag=%d%%\n", - title, count, (unsigned long)mon.free_size, mon.frag_pct); - if (mon.free_size < 8192) { // 8KB minimum — raised from 4KB after OOM crash (#119) - logPrintf("[LVGL/MEM] ABORT: insufficient heap (%lu < 8192) for picker!\n", - (unsigned long)mon.free_size); - return NULL; - } - - // Full-screen semi-transparent overlay - lv_obj_t* overlay = lv_obj_create(lv_screen_active()); - if (!overlay) { logPrintln("[LVGL/MEM] ListPicker overlay alloc failed"); return NULL; } - lv_obj_remove_style_all(overlay); - lv_obj_set_size(overlay, SCREEN_W, SCREEN_H); - lv_obj_set_pos(overlay, 0, 0); - lv_obj_set_style_bg_color(overlay, FC_COLOR_BG, 0); - lv_obj_set_style_bg_opa(overlay, LV_OPA_50, 0); - lv_obj_clear_flag(overlay, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_user_data(overlay, (void*)caller); - - // Modal container: 420x220, centered - lv_obj_t* box = lv_obj_create(overlay); - if (!box) { logPrintln("[LVGL/MEM] ListPicker box alloc failed"); lv_obj_delete(overlay); return NULL; } - lv_obj_remove_style_all(box); - lv_obj_set_size(box, 420, 220); - lv_obj_center(box); - lv_obj_set_style_bg_color(box, FC_COLOR_W_OVERLAY, 0); - lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(box, FC_COLOR_DIM, 0); - lv_obj_set_style_border_width(box, 1, 0); - lv_obj_set_style_radius(box, 8, 0); - lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE); - - // Title label - lv_obj_t* titleLbl = lv_label_create(box); - if (!titleLbl) { lv_obj_delete(overlay); return NULL; } - lv_label_set_text(titleLbl, title); - lv_obj_set_style_text_color(titleLbl, FC_COLOR_HEADER, 0); - lv_obj_set_style_text_font(titleLbl, FC_FONT_SM, 0); - lv_obj_set_pos(titleLbl, 10, 6); - - // Scrollable list area - lv_obj_t* listArea = lv_obj_create(box); - if (!listArea) { lv_obj_delete(overlay); return NULL; } - lv_obj_remove_style_all(listArea); - lv_obj_set_size(listArea, 410, 180); - lv_obj_set_pos(listArea, 5, 32); - lv_obj_set_style_bg_opa(listArea, LV_OPA_TRANSP, 0); - lv_obj_set_flex_flow(listArea, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(listArea, 4, 0); - - // List items - for (int i = 0; i < count; i++) { - lv_obj_t* itemBtn = lv_button_create(listArea); - if (!itemBtn) break; // OOM mid-loop — stop gracefully - lv_obj_set_size(itemBtn, 400, 32); - lv_obj_set_style_bg_color(itemBtn, - (i == selectedIdx) ? FC_COLOR_W_OK : FC_COLOR_W_OVERLAY, 0); - lv_obj_set_style_radius(itemBtn, 4, 0); - lv_obj_set_style_min_height(itemBtn, 32, 0); - lv_obj_set_user_data(itemBtn, (void*)(intptr_t)i); - lv_obj_add_event_cb(itemBtn, fcListPickerItemCb, LV_EVENT_CLICKED, NULL); - - lv_obj_t* itemLbl = lv_label_create(itemBtn); - if (!itemLbl) break; - lv_label_set_text(itemLbl, items[i]); - lv_obj_set_style_text_color(itemLbl, FC_COLOR_TEXT, 0); - lv_obj_set_style_text_font(itemLbl, FC_FONT_SM, 0); - lv_obj_align(itemLbl, LV_ALIGN_LEFT_MID, 10, 0); - } - - // Scroll to selected item - if (selectedIdx > 0 && selectedIdx < count) { - lv_obj_t* selBtn = lv_obj_get_child(listArea, selectedIdx); - if (selBtn) lv_obj_scroll_to_view(selBtn, LV_ANIM_OFF); - } - - fcListPickerActiveOverlay = overlay; - return overlay; -} - -// ============== LVGL Compass Screen (#109) ============== - -// Compass screen container and child widgets -static lv_obj_t* compassScr = NULL; // Root container (full screen) -static lv_obj_t* compassHeader = NULL; // fcHeader widget -static lv_obj_t* compassNavBar = NULL; // fcNavBar widget -static lv_obj_t* compassRoseObj = NULL; // Custom draw rose area -static lv_obj_t* compassLblHdg = NULL; // "204° SW" -static lv_obj_t* compassLblLat = NULL; // "Lat 39.3525N" -static lv_obj_t* compassLblLon = NULL; // "Lon 84.3825W" -static lv_obj_t* compassLblAlt = NULL; // "Alt 820 ft" -static lv_obj_t* compassLblSpd = NULL; // "Spd 2.3 mph" -static lv_obj_t* compassLblTemp = NULL; // "Temp 72.5°F" -static lv_obj_t* compassLblFcst = NULL; // "↑ Fair" -static lv_obj_t* compassLblGps = NULL; // "GPS OK Sat:8" -static lv_obj_t* compassLblTime = NULL; // "3:42:15 PM" - -// Cardinal direction labels (rotate with heading around rose) -static lv_obj_t* compassLblN = NULL; -static lv_obj_t* compassLblE = NULL; -static lv_obj_t* compassLblS = NULL; -static lv_obj_t* compassLblW = NULL; - -// Compass rose draw callback (forward declare — implemented in Task 2) -static void compassRoseDrawCb(lv_event_t* e); - -// Last drawn heading for invalidation threshold -static float compassLastHeading = -999.0f; - -// ============== LVGL Geocache Screen (#110) ============== - -// Root + sub-screen containers -static lv_obj_t* geocacheScr = NULL; // Root container -static lv_obj_t* geocacheNavCtr = NULL; // Nav sub-screen container -static lv_obj_t* geocacheListCtr = NULL; // List sub-screen container -static lv_obj_t* geocacheDetailsCtr = NULL; // Details sub-screen container - -// Nav sub-screen labels -static lv_obj_t* gcNavHeader = NULL; -static lv_obj_t* gcNavNavBar = NULL; -static lv_obj_t* gcNavLblName = NULL; -static lv_obj_t* gcNavLblDist = NULL; -static lv_obj_t* gcNavLblDT = NULL; -static lv_obj_t* gcNavLblBearing = NULL; -static lv_obj_t* gcNavGraphicObj = NULL; // Custom draw area -static lv_obj_t* gcNavLblAccuracy = NULL; -static lv_obj_t* gcNavLblHint = NULL; - -// List sub-screen handles -static lv_obj_t* gcListHeader = NULL; -static lv_obj_t* gcListNavBar = NULL; -static lv_obj_t* gcListLblCount = NULL; -static lv_obj_t* gcListScrollCtr = NULL; -static lv_obj_t* gcListLblHints = NULL; - -// List row handles (MAX_CACHES=20) -static lv_obj_t* gcListRows[MAX_CACHES] = {}; -static lv_obj_t* gcListRowLabel[MAX_CACHES] = {}; // Single formatted label per row - -// Details sub-screen labels -static lv_obj_t* gcDetHeader = NULL; -static lv_obj_t* gcDetNavBar = NULL; -static lv_obj_t* gcDetLblCount = NULL; -static lv_obj_t* gcDetLblName = NULL; -static lv_obj_t* gcDetLblGC = NULL; -static lv_obj_t* gcDetLblCoords = NULL; -static lv_obj_t* gcDetLblDT = NULL; -static lv_obj_t* gcDetLblDist = NULL; -static lv_obj_t* gcDetLblHintLabel = NULL; -static lv_obj_t* gcDetLblHint = NULL; -static lv_obj_t* gcDetLblFound = NULL; -static lv_obj_t* gcDetLblHints = NULL; - -// Filter sub-screen handles (#122) -static lv_obj_t* geocacheFilterCtr = NULL; -static lv_obj_t* gcFiltLblFound = NULL; -static lv_obj_t* gcFiltLblSort = NULL; -static lv_obj_t* gcFiltLblDRange = NULL; -static lv_obj_t* gcFiltLblTRange = NULL; -static lv_obj_t* gcFiltLblDist = NULL; -static lv_obj_t* gcFiltLblActive = NULL; // Active filter indicator on list screen - -// Nav graphic state tracking -static float gcNavLastBearing = -999; -static float gcNavLastHeading = -999; -static bool gcNavLastInZone = false; -static int32_t gcNavPulseRadius = 0; // For search zone animation - -// Forward declarations (#110) -static void geocacheNavDrawCb(lv_event_t* e); - -// ============== LVGL Environment Screen (#111) ============== - -// Root container -static lv_obj_t* envScr = NULL; - -// Header + NavBar -static lv_obj_t* envHeader = NULL; -static lv_obj_t* envNavBar = NULL; - -// Label pairs: dim label + colored value -static lv_obj_t* envLblTempLabel = NULL; -static lv_obj_t* envLblTempValue = NULL; -static lv_obj_t* envLblHumidLabel = NULL; -static lv_obj_t* envLblHumidValue = NULL; -static lv_obj_t* envLblIaqLabel = NULL; -static lv_obj_t* envLblIaqValue = NULL; -static lv_obj_t* envLblCo2Label = NULL; -static lv_obj_t* envLblCo2Value = NULL; -static lv_obj_t* envLblPressLabel = NULL; -static lv_obj_t* envLblPressValue = NULL; -static lv_obj_t* envLblFcstLabel = NULL; -static lv_obj_t* envLblFcstValue = NULL; - -// Error state label (no sensors) -static lv_obj_t* envLblNoSensors = NULL; - -// ============== LVGL Telemetry Screen (#111) ============== - -// Root container -static lv_obj_t* telemetryScr = NULL; - -// Header + NavBar -static lv_obj_t* telHeader = NULL; -static lv_obj_t* telNavBar = NULL; - -// Section labels -static lv_obj_t* telLblGpsSection = NULL; -static lv_obj_t* telLblImuSection = NULL; - -// GPS data labels (left column: label+value, right column: label+value) -static lv_obj_t* telLblLatLabel = NULL; -static lv_obj_t* telLblLatValue = NULL; -static lv_obj_t* telLblLonLabel = NULL; -static lv_obj_t* telLblLonValue = NULL; -static lv_obj_t* telLblAltLabel = NULL; -static lv_obj_t* telLblAltValue = NULL; -static lv_obj_t* telLblSpdLabel = NULL; -static lv_obj_t* telLblSpdValue = NULL; -static lv_obj_t* telLblSatLabel = NULL; -static lv_obj_t* telLblSatValue = NULL; -static lv_obj_t* telLblHdopLabel = NULL; -static lv_obj_t* telLblHdopValue = NULL; -static lv_obj_t* telLblStatusLabel = NULL; -static lv_obj_t* telLblStatusValue = NULL; - -// GPS acquiring/error state labels -static lv_obj_t* telLblGpsAcquiring = NULL; -static lv_obj_t* telLblGpsElapsed = NULL; -static lv_obj_t* telLblGpsSkyHint = NULL; -static lv_obj_t* telLblGpsSatCount = NULL; -static lv_obj_t* telLblGpsNoData = NULL; -static lv_obj_t* telLblGpsCheckConn = NULL; - -// Divider line -static lv_obj_t* telDivider = NULL; - -// IMU data labels -static lv_obj_t* telLblHdgLabel = NULL; -static lv_obj_t* telLblHdgValue = NULL; -static lv_obj_t* telLblRollLabel = NULL; -static lv_obj_t* telLblRollValue = NULL; -static lv_obj_t* telLblPitchLabel = NULL; -static lv_obj_t* telLblPitchValue = NULL; -static lv_obj_t* telLblAccelLabel = NULL; -static lv_obj_t* telLblAccelValue = NULL; - -// IMU error state -static lv_obj_t* telLblNoImu = NULL; - -// ============== LVGL Settings Screen (#112) ============== - -// Root + sub-screen containers -static lv_obj_t* settingsScr = NULL; -static lv_obj_t* settingsMenuCtr = NULL; -static lv_obj_t* settingsConfigCtr = NULL; -static lv_obj_t* settingsDisplayCtr = NULL; -static lv_obj_t* settingsCalCtr = NULL; -static lv_obj_t* settingsDiagsCtr = NULL; -static lv_obj_t* settingsAboutCtr = NULL; -static lv_obj_t* settingsResetCtr = NULL; -static lv_obj_t* settingsMenuBtns[6]; - -// Scroll areas for settings sub-screens (#101) -static lv_obj_t* settingsScrollAreas[7] = {NULL}; -#define SETTINGS_SCROLL_STEP 50 // Pixels per A/B button press - -// Helper: create a scrollable content area between header and action bar (#101) -static lv_obj_t* fcSettingsScrollCreate(lv_obj_t* parent, int idx) { - lv_obj_t* scroll = lv_obj_create(parent); - lv_obj_remove_style_all(scroll); - lv_obj_set_size(scroll, SCREEN_W, 240); // 320 - 30 header - 50 action bar - lv_obj_set_pos(scroll, 0, 30); - lv_obj_set_style_bg_opa(scroll, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(scroll, 0, 0); - lv_obj_set_style_pad_all(scroll, 0, 0); - // Vertical scroll only, auto-show scrollbar when content overflows - lv_obj_set_scroll_dir(scroll, LV_DIR_VER); - lv_obj_set_scrollbar_mode(scroll, LV_SCROLLBAR_MODE_AUTO); - lv_obj_set_style_bg_color(scroll, lv_color_hex(0x808080), LV_PART_SCROLLBAR); - lv_obj_set_style_bg_opa(scroll, LV_OPA_COVER, LV_PART_SCROLLBAR); - lv_obj_set_style_width(scroll, 4, LV_PART_SCROLLBAR); - lv_obj_clear_flag(scroll, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_CLICKABLE)); - settingsScrollAreas[idx] = scroll; - return scroll; -} - -// Config sub-screen widget pointers (#112) -static lv_obj_t* cfgTzDropdown = NULL; -static lv_obj_t* cfgTimeToggle = NULL; -static lv_obj_t* cfgTempToggle = NULL; -static lv_obj_t* cfgDistToggle = NULL; -static lv_obj_t* cfgPreviewLabel = NULL; - -// Display sub-screen widget pointers (#112) -static lv_obj_t* dispBrightnessSlider = NULL; -static lv_obj_t* dispBrightnessLabel = NULL; -static lv_obj_t* dispTftDropdown = NULL; -static lv_obj_t* dispOledDropdown = NULL; - -// Compass Cal sub-screen widget pointers (#112) -// Idle state -static lv_obj_t* calStatusLabel = NULL; -static lv_obj_t* calOffsetsLabel = NULL; -static lv_obj_t* calStartBtn = NULL; -static lv_obj_t* calIdleActBar = NULL; -// Active state (hidden during idle) -static lv_obj_t* calArc = NULL; -static lv_obj_t* calCountdownLabel = NULL; -static lv_obj_t* calInstructLabel = NULL; -static lv_obj_t* calMinMaxLabel = NULL; - -// Diagnostics sub-screen widget pointers (#112) -static lv_obj_t* diagValueLabels[11]; // 11 value labels updated each frame - -// About sub-screen widget pointers (#112) -static lv_obj_t* aboutValueLabels[6]; // 6 value labels (some live-updating) - -void buildCompassScreen() { - // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() - compassScr = lv_obj_create(NULL); - lv_obj_set_size(compassScr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(compassScr, FC_COLOR_BG, 0); - lv_obj_set_style_border_width(compassScr, 0, 0); - lv_obj_set_style_radius(compassScr, 0, 0); - lv_obj_set_style_pad_all(compassScr, 0, 0); - lv_obj_clear_flag(compassScr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(compassScr, screenGestureCb, LV_EVENT_GESTURE, NULL); - - // Header bar (reuse #108 widget) - compassHeader = fcHeaderCreate(compassScr, "COMPASS"); - - // Vertical separator line between panels - lv_obj_t* sep = lv_obj_create(compassScr); - lv_obj_remove_style_all(sep); - lv_obj_set_size(sep, 1, 256); - lv_obj_set_pos(sep, 178, 34); - lv_obj_set_style_bg_color(sep, lv_color_hex(0x212121), 0); - lv_obj_set_style_bg_opa(sep, LV_OPA_COVER, 0); - - // === Left Panel Labels === - - // Heading + cardinal (large green text) - compassLblHdg = lv_label_create(compassScr); - lv_obj_set_pos(compassLblHdg, 8, 34); - lv_obj_set_style_text_font(compassLblHdg, FC_FONT_XXL, 0); - lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblHdg, "--"); - - // Latitude - compassLblLat = lv_label_create(compassScr); - lv_obj_set_pos(compassLblLat, 8, 72); - lv_obj_set_style_text_font(compassLblLat, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblLat, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblLat, "Lat --"); - - // Longitude - compassLblLon = lv_label_create(compassScr); - lv_obj_set_pos(compassLblLon, 8, 90); - lv_obj_set_style_text_font(compassLblLon, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblLon, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblLon, "Lon --"); - - // Altitude - compassLblAlt = lv_label_create(compassScr); - lv_obj_set_pos(compassLblAlt, 8, 112); - lv_obj_set_style_text_font(compassLblAlt, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblAlt, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblAlt, "Alt --"); - - // Speed - compassLblSpd = lv_label_create(compassScr); - lv_obj_set_pos(compassLblSpd, 8, 132); - lv_obj_set_style_text_font(compassLblSpd, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblSpd, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblSpd, "Spd --"); - - // Temperature - compassLblTemp = lv_label_create(compassScr); - lv_obj_set_pos(compassLblTemp, 8, 152); - lv_obj_set_style_text_font(compassLblTemp, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblTemp, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblTemp, "Temp --"); - - // Forecast - compassLblFcst = lv_label_create(compassScr); - lv_obj_set_pos(compassLblFcst, 8, 172); - lv_obj_set_style_text_font(compassLblFcst, FC_FONT_SM, 0); - lv_obj_set_style_text_color(compassLblFcst, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblFcst, "Fcst --"); - - // GPS status - compassLblGps = lv_label_create(compassScr); - lv_obj_set_pos(compassLblGps, 8, 200); - lv_obj_set_style_text_font(compassLblGps, FC_FONT_XS, 0); - lv_obj_set_style_text_color(compassLblGps, FC_COLOR_VALUE, 0); - lv_label_set_text(compassLblGps, "GPS --"); - - // Time (bottom of left panel) - compassLblTime = lv_label_create(compassScr); - lv_obj_set_pos(compassLblTime, 8, 268); - lv_obj_set_style_text_font(compassLblTime, FC_FONT_LG, 0); - lv_obj_set_style_text_color(compassLblTime, FC_COLOR_TEXT, 0); - lv_label_set_text(compassLblTime, "--:--"); - - // === Right Panel: Compass Rose (custom draw) === - compassRoseObj = lv_obj_create(compassScr); - lv_obj_remove_style_all(compassRoseObj); - lv_obj_set_size(compassRoseObj, 298, 260); - lv_obj_set_pos(compassRoseObj, 182, 30); - lv_obj_clear_flag(compassRoseObj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(compassRoseObj, compassRoseDrawCb, LV_EVENT_DRAW_MAIN, NULL); - - // Lubber line — fixed orange triangle at top of rose (does not rotate) - // Uses a small filled rectangle as approximation; future polish can use draw callback - lv_obj_t* lubber = lv_obj_create(compassScr); - lv_obj_remove_style_all(lubber); - lv_obj_set_size(lubber, 14, 10); - lv_obj_set_pos(lubber, 324, 40); // Just above outer ring (top at y=52), below header (y=30) - lv_obj_set_style_bg_color(lubber, FC_COLOR_WARN, 0); - lv_obj_set_style_bg_opa(lubber, LV_OPA_COVER, 0); - lv_obj_set_style_radius(lubber, 2, 0); - - // Cardinal direction labels (N/E/S/W — positioned dynamically in updateCompassData) - const char* cardinals[] = {"N", "E", "S", "W"}; - lv_obj_t** cardLbls[] = {&compassLblN, &compassLblE, &compassLblS, &compassLblW}; - lv_color_t cardColors[] = { - lv_color_hex(0x00FFFF), // N cyan (matches north needle) - lv_color_hex(0xFFFFFF), // E white - lv_color_hex(0xFF0000), // S red (matches south needle) - lv_color_hex(0xFFFFFF), // W white - }; - for (int i = 0; i < 4; i++) { - *cardLbls[i] = lv_label_create(compassScr); - lv_label_set_text(*cardLbls[i], cardinals[i]); - lv_obj_set_style_text_font(*cardLbls[i], FC_FONT_SM, 0); - lv_obj_set_style_text_color(*cardLbls[i], cardColors[i], 0); - lv_obj_set_pos(*cardLbls[i], -50, -50); // Off-screen until first heading update - } - - // NavBar at bottom - compassNavBar = fcNavBarCreate(compassScr, NUM_SCREENS, SCREEN_COMPASS); - - - logPrintln("[LVGL] Compass screen built (#109)"); -} - -// Compass rose custom draw callback — anti-aliased via LVGL primitives (#109) -static void compassRoseDrawCb(lv_event_t* e) { - lv_obj_t* obj = (lv_obj_t*)lv_event_get_target(e); - lv_layer_t* layer = lv_event_get_layer(e); - - // Rose geometry — center of the 298x260 container - lv_area_t coords; - lv_obj_get_coords(obj, &coords); - int32_t objW = lv_obj_get_width(obj); - int32_t objH = lv_obj_get_height(obj); - int32_t cx = coords.x1 + objW / 2; - int32_t cy = coords.y1 + objH / 2; - int32_t radius = 108; - - float heading = compassLastHeading; - if (heading < 0) heading = 0; - float rotDeg = -heading; - - // --- Outer ring (anti-aliased arc) --- - lv_draw_arc_dsc_t arcDsc; - lv_draw_arc_dsc_init(&arcDsc); - arcDsc.center.x = cx; - arcDsc.center.y = cy; - arcDsc.radius = radius; - arcDsc.width = 2; - arcDsc.start_angle = 0; - arcDsc.end_angle = 360; - arcDsc.color = lv_color_hex(0x808080); - arcDsc.opa = LV_OPA_COVER; - lv_draw_arc(layer, &arcDsc); - - // --- Tick marks (12 ticks every 30°) --- - int tickCardLen = radius / 10; - int tickInterLen = radius / 20; - - for (int i = 0; i < 12; i++) { - float tickAngle = (i * 30.0f + rotDeg - 90.0f) * (float)M_PI / 180.0f; - int tickLen = (i % 3 == 0) ? tickCardLen : tickInterLen; - - lv_draw_line_dsc_t lineDsc; - lv_draw_line_dsc_init(&lineDsc); - lineDsc.p1.x = cx + (int32_t)(cosf(tickAngle) * (radius - tickLen)); - lineDsc.p1.y = cy + (int32_t)(sinf(tickAngle) * (radius - tickLen)); - lineDsc.p2.x = cx + (int32_t)(cosf(tickAngle) * radius); - lineDsc.p2.y = cy + (int32_t)(sinf(tickAngle) * radius); - lineDsc.width = (i % 3 == 0) ? 2 : 1; - lineDsc.color = lv_color_hex(0x808080); - lineDsc.opa = LV_OPA_COVER; - lv_draw_line(layer, &lineDsc); - } - - // --- 8 Diamond needles --- - struct { - float angle; - int length; - int halfWidth; - lv_color_t color; - lv_color_t tailColor; - } needles[] = { - { 0, radius*93/100, radius*10/100, lv_color_hex(0x00FFFF), lv_color_hex(0x212121)}, // N cyan - { 45, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // NE gray - { 90, radius*93/100, radius*10/100, lv_color_hex(0xFFFFFF), lv_color_hex(0x212121)}, // E white - {135, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // SE gray - {180, radius*93/100, radius*10/100, lv_color_hex(0xFF0000), lv_color_hex(0x212121)}, // S red - {225, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // SW gray - {270, radius*93/100, radius*10/100, lv_color_hex(0xFFFFFF), lv_color_hex(0x212121)}, // W white - {315, radius*60/100, radius*6/100, lv_color_hex(0x808080), lv_color_hex(0x808080)}, // NW gray - }; - - for (int i = 0; i < 8; i++) { - float tipRad = (needles[i].angle + rotDeg - 90.0f) * (float)M_PI / 180.0f; - float perpRad = tipRad + (float)M_PI / 2.0f; - - int32_t tipX = cx + (int32_t)(cosf(tipRad) * needles[i].length); - int32_t tipY = cy + (int32_t)(sinf(tipRad) * needles[i].length); - - int32_t sX1 = cx + (int32_t)(cosf(perpRad) * needles[i].halfWidth); - int32_t sY1 = cy + (int32_t)(sinf(perpRad) * needles[i].halfWidth); - int32_t sX2 = cx - (int32_t)(cosf(perpRad) * needles[i].halfWidth); - int32_t sY2 = cy - (int32_t)(sinf(perpRad) * needles[i].halfWidth); - - float tailRad = tipRad + (float)M_PI; - int tailLen = needles[i].length / 3; - int32_t tailX = cx + (int32_t)(cosf(tailRad) * tailLen); - int32_t tailY = cy + (int32_t)(sinf(tailRad) * tailLen); - - // Tip triangle (front half of diamond) - lv_draw_triangle_dsc_t triDsc; - lv_draw_triangle_dsc_init(&triDsc); - triDsc.p[0].x = tipX; triDsc.p[0].y = tipY; - triDsc.p[1].x = sX1; triDsc.p[1].y = sY1; - triDsc.p[2].x = sX2; triDsc.p[2].y = sY2; - triDsc.color = needles[i].color; - triDsc.opa = LV_OPA_COVER; - lv_draw_triangle(layer, &triDsc); - - // Tail triangle (back half of diamond) - triDsc.p[0].x = tailX; triDsc.p[0].y = tailY; - triDsc.color = needles[i].tailColor; - lv_draw_triangle(layer, &triDsc); - } - - // --- Center hub (filled circle via thick arc) --- - int hubR = max(5, (int)(radius / 15)); - lv_draw_arc_dsc_t hubDsc; - lv_draw_arc_dsc_init(&hubDsc); - hubDsc.center.x = cx; - hubDsc.center.y = cy; - hubDsc.radius = hubR; - hubDsc.width = hubR; - hubDsc.start_angle = 0; - hubDsc.end_angle = 360; - hubDsc.color = lv_color_hex(0xFFFFFF); - hubDsc.opa = LV_OPA_COVER; - lv_draw_arc(layer, &hubDsc); - - // Hub outline - lv_draw_arc_dsc_t hubOutDsc; - lv_draw_arc_dsc_init(&hubOutDsc); - hubOutDsc.center.x = cx; - hubOutDsc.center.y = cy; - hubOutDsc.radius = hubR; - hubOutDsc.width = 1; - hubOutDsc.start_angle = 0; - hubOutDsc.end_angle = 360; - hubOutDsc.color = lv_color_hex(0x808080); - hubOutDsc.opa = LV_OPA_COVER; - lv_draw_arc(layer, &hubOutDsc); -} - -// Update left panel labels from sensor data (called from updateDisplay at 2Hz) -void updateCompassData() { - char buf[64]; - - // Section 1: Heading + cardinal - if (imuAvailable && magAvailable) { - const char* card = getCardinal(imuData.heading); - lv_label_set_text_fmt(compassLblHdg, "%.0f\xC2\xB0 %s", imuData.heading, card); - lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_VALUE, 0); - } else { - lv_label_set_text(compassLblHdg, "No IMU"); - lv_obj_set_style_text_color(compassLblHdg, FC_COLOR_ERROR, 0); - } - - // Section 2: GPS coordinates - if (gpsData.valid) { - lv_label_set_text_fmt(compassLblLat, "Lat %.4f%c", - fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S'); - lv_label_set_text_fmt(compassLblLon, "Lon %.4f%c", - fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W'); - lv_obj_set_style_text_color(compassLblLat, FC_COLOR_VALUE, 0); - lv_obj_set_style_text_color(compassLblLon, FC_COLOR_VALUE, 0); - } else if (gpsData.receiving) { - lv_label_set_text(compassLblLat, "GPS Acquiring..."); - lv_label_set_text_fmt(compassLblLon, "Sats: %d", gpsData.satellites); - lv_obj_set_style_text_color(compassLblLat, FC_COLOR_WARN, 0); - lv_obj_set_style_text_color(compassLblLon, FC_COLOR_WARN, 0); - } else { - lv_label_set_text(compassLblLat, "No GPS"); - lv_label_set_text(compassLblLon, ""); - lv_obj_set_style_text_color(compassLblLat, FC_COLOR_ERROR, 0); - } - - // Section 3: Altitude - if (gpsData.valid) { - float alt = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; - lv_label_set_text_fmt(compassLblAlt, "Alt %.0f %s", - alt, useMetricUnits ? "m" : "ft"); - } else { - lv_label_set_text(compassLblAlt, "Alt --"); - } - - // Section 4: Speed - if (gpsData.valid) { - float speed = gpsData.speedKnots * (useMetricUnits ? 1.852 : 1.15078); - lv_label_set_text_fmt(compassLblSpd, "Spd %.1f %s", - speed, useMetricUnits ? "km/h" : "mph"); - } else { - lv_label_set_text_fmt(compassLblSpd, "Spd -- %s", - useMetricUnits ? "km/h" : "mph"); - } - - // Section 5: Temperature - bool hasTempSensor = shtAvailable || bmeAvailable; - if (hasTempSensor) { - float tempC = shtAvailable ? shtData.temperature : envData.temperature; - float tempDisplay = useFahrenheit ? tempC * 9.0 / 5.0 + 32.0 : tempC; - lv_label_set_text_fmt(compassLblTemp, "Temp %.1f\xC2\xB0%c", - tempDisplay, useFahrenheit ? 'F' : 'C'); - } else { - lv_label_set_text(compassLblTemp, "Temp --"); - } - - // Section 6: Forecast with color coding - const char* fc = weatherTrend.forecast; - lv_color_t fcstColor = FC_COLOR_VALUE; - if (strstr(fc, "Storm")) fcstColor = FC_COLOR_ERROR; - else if (strstr(fc, "Rain") || strstr(fc, "Snow") || - strstr(fc, "Unsettled") || strstr(fc, "Precip")) fcstColor = FC_COLOR_WARN; - else if (strcmp(fc, "Init") == 0 || strcmp(fc, "Learning") == 0 || - strcmp(fc, "Traveled") == 0) fcstColor = FC_COLOR_DIM; - lv_obj_set_style_text_color(compassLblFcst, fcstColor, 0); - lv_label_set_text_fmt(compassLblFcst, "Fcst %s %s", getTrendArrow(), fc); - - // Section 7: GPS status - if (gpsData.valid) { - lv_label_set_text_fmt(compassLblGps, "GPS OK Sat:%d HDOP:%.1f", - gpsData.satellites, gpsData.hdop); - lv_obj_set_style_text_color(compassLblGps, FC_COLOR_VALUE, 0); - } else if (gpsData.receiving) { - lv_label_set_text_fmt(compassLblGps, "GPS Acquiring Sat:%d", - gpsData.satellites); - lv_obj_set_style_text_color(compassLblGps, FC_COLOR_WARN, 0); - } else { - lv_label_set_text(compassLblGps, "No GPS"); - lv_obj_set_style_text_color(compassLblGps, FC_COLOR_ERROR, 0); - } - - // Section 8: Time - char timeBuf[16]; - struct tm timeinfo; - if (getLocalTime(&timeinfo, 0)) { - formatTimeStr(timeBuf, timeinfo.tm_hour, timeinfo.tm_min, - timeinfo.tm_sec, false); - } else { - strcpy(timeBuf, "--:--"); - } - lv_label_set_text(compassLblTime, timeBuf); - - // Section 9: Invalidate compass rose if heading changed >2° - if (imuAvailable && magAvailable) { - float diff = fabs(imuData.heading - compassLastHeading); - if (diff > 180) diff = 360 - diff; // Wrap-around - if (diff >= 2.0f) { - compassLastHeading = imuData.heading; - lv_obj_invalidate(compassRoseObj); - - // Section 10: Reposition cardinal direction labels around rose - if (compassLblN) { - int32_t roseCx = 331; // Center of rose (182 + 298/2) - int32_t roseCy = 160; // Center of rose (30 + 260/2) - int32_t labelR = 120; // Just outside outer ring (radius=108 + padding) - float cardAngles[] = {0, 90, 180, 270}; - lv_obj_t* cardObjs[] = {compassLblN, compassLblE, compassLblS, compassLblW}; - float rot = -compassLastHeading; - - for (int ci = 0; ci < 4; ci++) { - float rad = (cardAngles[ci] + rot - 90.0f) * (float)M_PI / 180.0f; - int32_t lx = roseCx + (int32_t)(cosf(rad) * labelR) - 5; // ~half char width - int32_t ly = roseCy + (int32_t)(sinf(rad) * labelR) - 8; // ~half char height - lv_obj_set_pos(cardObjs[ci], lx, ly); - } - } - } - } -} - -// ============== LVGL Geocache Nav Draw Callback (#110) ============== - -static void geocacheNavDrawCb(lv_event_t* e) { - lv_obj_t* obj = (lv_obj_t*)lv_event_get_target(e); - lv_layer_t* layer = lv_event_get_layer(e); - - lv_area_t coords; - lv_obj_get_coords(obj, &coords); - int32_t cx = (coords.x1 + coords.x2) / 2; - int32_t cy = (coords.y1 + coords.y2) / 2; - - // Need valid cache and GPS data to draw - if (cacheListCount == 0 || !cacheList[selectedCacheIndex].valid) return; - if (!gpsData.valid) return; - - GeocacheEntry& cache = cacheList[selectedCacheIndex]; - float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - float distM = distKm * 1000.0f; - float bearing = calcBearing(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - float accuracyM = getGpsAccuracyMeters(); - bool inSearchZone = (distM < accuracyM); - - if (inSearchZone) { - // === Search Zone Circle (pulsing) === - float ratio = (accuracyM > 0) ? (distM / accuracyM) : 0; - int32_t baseR = 20 + (int32_t)((1.0f - ratio) * 40.0f); // 20-60px - int32_t r = baseR + gcNavPulseRadius; - - // Filled orange circle - lv_draw_arc_dsc_t arcDsc; - lv_draw_arc_dsc_init(&arcDsc); - arcDsc.center.x = cx; - arcDsc.center.y = cy; - arcDsc.radius = r; - arcDsc.start_angle = 0; - arcDsc.end_angle = 360; - arcDsc.color = FC_COLOR_WARN; - arcDsc.opa = LV_OPA_COVER; - arcDsc.width = r; // Filled - lv_draw_arc(layer, &arcDsc); - - // White outline - lv_draw_arc_dsc_t outDsc; - lv_draw_arc_dsc_init(&outDsc); - outDsc.center.x = cx; - outDsc.center.y = cy; - outDsc.radius = r + 2; - outDsc.start_angle = 0; - outDsc.end_angle = 360; - outDsc.color = FC_COLOR_TEXT; - outDsc.opa = LV_OPA_COVER; - outDsc.width = 2; - lv_draw_arc(layer, &outDsc); - - // Center dot - lv_draw_arc_dsc_t dotDsc; - lv_draw_arc_dsc_init(&dotDsc); - dotDsc.center.x = cx; - dotDsc.center.y = cy; - dotDsc.radius = 4; - dotDsc.start_angle = 0; - dotDsc.end_angle = 360; - dotDsc.color = FC_COLOR_TEXT; - dotDsc.opa = LV_OPA_COVER; - dotDsc.width = 4; - lv_draw_arc(layer, &dotDsc); - } else { - // === Direction Arrow (same math as legacy drawNavTriangle) === - float triangleAngle = bearing - imuData.heading; - if (triangleAngle < 0) triangleAngle += 360; - if (triangleAngle >= 360) triangleAngle -= 360; - - int size = 50; - float rad = (triangleAngle - 90.0f) * (float)M_PI / 180.0f; - - // Tip point - int32_t tipX = cx + (int32_t)(cosf(rad) * size); - int32_t tipY = cy + (int32_t)(sinf(rad) * size); - - // Rear corners (±140° from tip direction) - float rear1Rad = rad + 140.0f * (float)M_PI / 180.0f; - float rear2Rad = rad - 140.0f * (float)M_PI / 180.0f; - int32_t rear1X = cx + (int32_t)(cosf(rear1Rad) * size * 0.7f); - int32_t rear1Y = cy + (int32_t)(sinf(rear1Rad) * size * 0.7f); - int32_t rear2X = cx + (int32_t)(cosf(rear2Rad) * size * 0.7f); - int32_t rear2Y = cy + (int32_t)(sinf(rear2Rad) * size * 0.7f); - - // Rear center notch - float rearCRad = rad + 180.0f * (float)M_PI / 180.0f; - int32_t rearCX = cx + (int32_t)(cosf(rearCRad) * size * 0.3f); - int32_t rearCY = cy + (int32_t)(sinf(rearCRad) * size * 0.3f); - - lv_color_t arrowColor = FC_COLOR_HEADER; // Cyan - - // Triangle 1: tip → rear1 → rearCenter - lv_draw_triangle_dsc_t tri1; - lv_draw_triangle_dsc_init(&tri1); - tri1.p[0].x = tipX; tri1.p[0].y = tipY; - tri1.p[1].x = rear1X; tri1.p[1].y = rear1Y; - tri1.p[2].x = rearCX; tri1.p[2].y = rearCY; - tri1.color = arrowColor; - tri1.opa = LV_OPA_COVER; - lv_draw_triangle(layer, &tri1); - - // Triangle 2: tip → rear2 → rearCenter - lv_draw_triangle_dsc_t tri2; - lv_draw_triangle_dsc_init(&tri2); - tri2.p[0].x = tipX; tri2.p[0].y = tipY; - tri2.p[1].x = rear2X; tri2.p[1].y = rear2Y; - tri2.p[2].x = rearCX; tri2.p[2].y = rearCY; - tri2.color = arrowColor; - tri2.opa = LV_OPA_COVER; - lv_draw_triangle(layer, &tri2); - } -} - -// ============== LVGL Geocache Screen Builder (#110) ============== - -void buildGeocacheScreen() { - // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() - geocacheScr = lv_obj_create(NULL); - lv_obj_set_size(geocacheScr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(geocacheScr, FC_COLOR_BG, 0); - lv_obj_set_style_border_width(geocacheScr, 0, 0); - lv_obj_set_style_radius(geocacheScr, 0, 0); - lv_obj_set_style_pad_all(geocacheScr, 0, 0); - lv_obj_clear_flag(geocacheScr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(geocacheScr, screenGestureCb, LV_EVENT_GESTURE, NULL); - - // Nav sub-screen container (sub 0) - geocacheNavCtr = lv_obj_create(geocacheScr); - lv_obj_remove_style_all(geocacheNavCtr); - lv_obj_set_size(geocacheNavCtr, SCREEN_W, SCREEN_H); - lv_obj_set_pos(geocacheNavCtr, 0, 0); - lv_obj_clear_flag(geocacheNavCtr, LV_OBJ_FLAG_SCROLLABLE); - - // === Nav Sub-screen widgets (sub 0) === - gcNavHeader = fcHeaderCreate(geocacheNavCtr, "GEOCACHE"); - - // Cache name (centered) - gcNavLblName = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblName, 0, 33); - lv_obj_set_width(gcNavLblName, SCREEN_W); - lv_obj_set_style_text_font(gcNavLblName, FC_FONT_LG, 0); - lv_obj_set_style_text_color(gcNavLblName, FC_COLOR_TEXT, 0); - lv_obj_set_style_text_align(gcNavLblName, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_long_mode(gcNavLblName, LV_LABEL_LONG_DOT); // Clip with "..." to prevent overlap - lv_label_set_text(gcNavLblName, "No cache loaded"); - - // Distance - gcNavLblDist = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblDist, 8, 57); - lv_obj_set_style_text_font(gcNavLblDist, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); - lv_label_set_text(gcNavLblDist, "--"); - - // Difficulty / Terrain - gcNavLblDT = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblDT, 140, 57); - lv_obj_set_style_text_font(gcNavLblDT, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcNavLblDT, FC_COLOR_DIM, 0); - lv_label_set_text(gcNavLblDT, ""); - - // Bearing - gcNavLblBearing = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblBearing, 300, 57); - lv_obj_set_style_text_font(gcNavLblBearing, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcNavLblBearing, FC_COLOR_VALUE, 0); - lv_label_set_text(gcNavLblBearing, ""); - - // Nav graphic area (custom draw — triangle or search zone) - gcNavGraphicObj = lv_obj_create(geocacheNavCtr); - lv_obj_remove_style_all(gcNavGraphicObj); - lv_obj_set_size(gcNavGraphicObj, 200, 120); - lv_obj_set_pos(gcNavGraphicObj, 140, 78); - lv_obj_clear_flag(gcNavGraphicObj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(gcNavGraphicObj, geocacheNavDrawCb, LV_EVENT_DRAW_MAIN, NULL); - - // Pulse animation for search zone circle - static lv_anim_t pulseAnim; - lv_anim_init(&pulseAnim); - lv_anim_set_var(&pulseAnim, gcNavGraphicObj); - lv_anim_set_values(&pulseAnim, 0, 8); - lv_anim_set_duration(&pulseAnim, 1000); - lv_anim_set_playback_duration(&pulseAnim, 1000); - lv_anim_set_repeat_count(&pulseAnim, LV_ANIM_REPEAT_INFINITE); - lv_anim_set_exec_cb(&pulseAnim, [](void* obj, int32_t val) { - gcNavPulseRadius = val; - if (geocacheSubScreen == 0 && gcNavLastInZone) { - lv_obj_invalidate((lv_obj_t*)obj); - } - }); - lv_anim_start(&pulseAnim); - - // Accuracy - gcNavLblAccuracy = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblAccuracy, 0, 200); - lv_obj_set_width(gcNavLblAccuracy, SCREEN_W); - lv_obj_set_style_text_font(gcNavLblAccuracy, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcNavLblAccuracy, FC_COLOR_VALUE, 0); - lv_obj_set_style_text_align(gcNavLblAccuracy, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(gcNavLblAccuracy, ""); - - // Hint - gcNavLblHint = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavLblHint, 8, 222); - lv_obj_set_width(gcNavLblHint, SCREEN_W - 16); - lv_obj_set_style_text_font(gcNavLblHint, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcNavLblHint, FC_COLOR_DIM, 0); - lv_label_set_long_mode(gcNavLblHint, LV_LABEL_LONG_WRAP); - lv_label_set_text(gcNavLblHint, ""); - - // "List" touch label in header area — tap to go to cache list (#122) - lv_obj_t* gcNavListBtn = lv_label_create(geocacheNavCtr); - lv_obj_set_pos(gcNavListBtn, 200, 7); - lv_obj_set_style_text_font(gcNavListBtn, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcNavListBtn, FC_COLOR_BG, 0); // Black on cyan header - lv_label_set_text(gcNavListBtn, "[List]"); - lv_obj_add_flag(gcNavListBtn, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcNavListBtn, 15); // +15px invisible hit padding - lv_obj_add_event_cb(gcNavListBtn, gcNavListBtnCb, LV_EVENT_CLICKED, NULL); - - // Nav bar - gcNavNavBar = fcNavBarCreate(geocacheNavCtr, NUM_SCREENS, SCREEN_GEOCACHE); - - // List sub-screen container (sub 1) - geocacheListCtr = lv_obj_create(geocacheScr); - lv_obj_remove_style_all(geocacheListCtr); - lv_obj_set_size(geocacheListCtr, SCREEN_W, SCREEN_H); - lv_obj_set_pos(geocacheListCtr, 0, 0); - lv_obj_clear_flag(geocacheListCtr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); - - // === List Sub-screen widgets (sub 1) === - gcListHeader = fcHeaderCreate(geocacheListCtr, "CACHE LIST"); - - // Count label in header area - gcListLblCount = lv_label_create(geocacheListCtr); - lv_obj_set_pos(gcListLblCount, 360, 7); - lv_obj_set_style_text_font(gcListLblCount, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcListLblCount, FC_COLOR_DIM, 0); - lv_label_set_text(gcListLblCount, ""); - - // Scrollable list container - gcListScrollCtr = lv_obj_create(geocacheListCtr); - lv_obj_remove_style_all(gcListScrollCtr); - lv_obj_set_size(gcListScrollCtr, SCREEN_W, 228); - lv_obj_set_pos(gcListScrollCtr, 0, 33); - lv_obj_set_style_bg_color(gcListScrollCtr, FC_COLOR_BG, 0); - lv_obj_set_style_bg_opa(gcListScrollCtr, LV_OPA_COVER, 0); - lv_obj_add_flag(gcListScrollCtr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_pad_row(gcListScrollCtr, 2, 0); - lv_obj_set_flex_flow(gcListScrollCtr, LV_FLEX_FLOW_COLUMN); - - // Pre-create all MAX_CACHES rows - for (int i = 0; i < MAX_CACHES; i++) { - gcListRows[i] = lv_obj_create(gcListScrollCtr); - lv_obj_remove_style_all(gcListRows[i]); - lv_obj_set_size(gcListRows[i], 460, 22); - lv_obj_clear_flag(gcListRows[i], LV_OBJ_FLAG_SCROLLABLE); - - // Single formatted label per row (crash fix: -80 LVGL objects) - gcListRowLabel[i] = lv_label_create(gcListRows[i]); - lv_obj_set_pos(gcListRowLabel[i], 4, 2); - lv_obj_set_width(gcListRowLabel[i], 452); - lv_obj_set_style_text_font(gcListRowLabel[i], FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcListRowLabel[i], FC_COLOR_TEXT, 0); - lv_label_set_long_mode(gcListRowLabel[i], LV_LABEL_LONG_CLIP); - lv_label_set_text(gcListRowLabel[i], ""); - - // Tap row to select (#122) - lv_obj_add_flag(gcListRows[i], LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(gcListRows[i], gcListRowTapCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); - - // Hide rows beyond current cache count - lv_obj_add_flag(gcListRows[i], LV_OBJ_FLAG_HIDDEN); - } - - // Button hints - gcListLblHints = lv_label_create(geocacheListCtr); - lv_obj_set_pos(gcListLblHints, 0, 265); - lv_obj_set_width(gcListLblHints, SCREEN_W); - lv_obj_set_style_text_font(gcListLblHints, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcListLblHints, FC_COLOR_DIM, 0); - lv_obj_set_style_text_align(gcListLblHints, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(gcListLblHints, "[A]Up [B]Down [C]Select [C+]Details"); - - // Active filter indicator in header area (#122) - gcFiltLblActive = lv_label_create(geocacheListCtr); - lv_obj_set_pos(gcFiltLblActive, 180, 7); - lv_obj_set_style_text_font(gcFiltLblActive, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcFiltLblActive, FC_COLOR_WARN, 0); - lv_label_set_text(gcFiltLblActive, ""); - - // Filter button (touchable label in header area) (#122) - lv_obj_t* gcFilterBtn = lv_label_create(geocacheListCtr); - lv_obj_set_pos(gcFilterBtn, 300, 7); - lv_obj_set_style_text_font(gcFilterBtn, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcFilterBtn, FC_COLOR_BG, 0); // Black on cyan header - lv_label_set_text(gcFilterBtn, "[Filter]"); - lv_obj_add_flag(gcFilterBtn, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFilterBtn, 15); - lv_obj_add_event_cb(gcFilterBtn, gcFilterBtnCb, LV_EVENT_CLICKED, NULL); - - // Nav bar - gcListNavBar = fcNavBarCreate(geocacheListCtr, NUM_SCREENS, SCREEN_GEOCACHE); - - // Details sub-screen container (sub 2) - geocacheDetailsCtr = lv_obj_create(geocacheScr); - lv_obj_remove_style_all(geocacheDetailsCtr); - lv_obj_set_size(geocacheDetailsCtr, SCREEN_W, SCREEN_H); - lv_obj_set_pos(geocacheDetailsCtr, 0, 0); - lv_obj_clear_flag(geocacheDetailsCtr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); - - // === Details Sub-screen widgets (sub 2) === - gcDetHeader = fcHeaderCreate(geocacheDetailsCtr, "CACHE DETAILS"); - - // Count in header area - gcDetLblCount = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblCount, 360, 7); - lv_obj_set_style_text_font(gcDetLblCount, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcDetLblCount, FC_COLOR_DIM, 0); - lv_label_set_text(gcDetLblCount, ""); - - // Cache name - gcDetLblName = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblName, 8, 35); - lv_obj_set_width(gcDetLblName, SCREEN_W - 16); - lv_obj_set_style_text_font(gcDetLblName, FC_FONT_LG, 0); - lv_obj_set_style_text_color(gcDetLblName, FC_COLOR_TEXT, 0); - lv_label_set_long_mode(gcDetLblName, LV_LABEL_LONG_CLIP); - lv_label_set_text(gcDetLblName, ""); - - // GC code - gcDetLblGC = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblGC, 8, 57); - lv_obj_set_style_text_font(gcDetLblGC, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcDetLblGC, FC_COLOR_HEADER, 0); - lv_label_set_text(gcDetLblGC, ""); - - // Coordinates - gcDetLblCoords = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblCoords, 8, 79); - lv_obj_set_style_text_font(gcDetLblCoords, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcDetLblCoords, FC_COLOR_VALUE, 0); - lv_label_set_text(gcDetLblCoords, ""); - - // Difficulty/Terrain - gcDetLblDT = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblDT, 8, 97); - lv_obj_set_style_text_font(gcDetLblDT, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcDetLblDT, FC_COLOR_DIM, 0); - lv_label_set_text(gcDetLblDT, ""); - - // Distance + bearing (dynamic) - gcDetLblDist = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblDist, 8, 119); - lv_obj_set_style_text_font(gcDetLblDist, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcDetLblDist, FC_COLOR_VALUE, 0); - lv_label_set_text(gcDetLblDist, ""); - - // Hint label - gcDetLblHintLabel = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblHintLabel, 8, 141); - lv_obj_set_style_text_font(gcDetLblHintLabel, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcDetLblHintLabel, FC_COLOR_DIM, 0); - lv_label_set_text(gcDetLblHintLabel, "Hint:"); - - // Hint text (wrapped) - gcDetLblHint = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblHint, 8, 159); - lv_obj_set_width(gcDetLblHint, SCREEN_W - 16); - lv_obj_set_style_text_font(gcDetLblHint, FC_FONT_SM, 0); - lv_obj_set_style_text_color(gcDetLblHint, FC_COLOR_DIM, 0); - lv_label_set_long_mode(gcDetLblHint, LV_LABEL_LONG_WRAP); - lv_label_set_text(gcDetLblHint, ""); - - // Found status - gcDetLblFound = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblFound, 0, 200); - lv_obj_set_width(gcDetLblFound, SCREEN_W); - lv_obj_set_style_text_font(gcDetLblFound, FC_FONT_MD, 0); - lv_obj_set_style_text_align(gcDetLblFound, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(gcDetLblFound, ""); - - // Button hints - gcDetLblHints = lv_label_create(geocacheDetailsCtr); - lv_obj_set_pos(gcDetLblHints, 0, 265); - lv_obj_set_width(gcDetLblHints, SCREEN_W); - lv_obj_set_style_text_font(gcDetLblHints, FC_FONT_XS, 0); - lv_obj_set_style_text_color(gcDetLblHints, FC_COLOR_DIM, 0); - lv_obj_set_style_text_align(gcDetLblHints, LV_TEXT_ALIGN_CENTER, 0); - lv_label_set_text(gcDetLblHints, "[A]Prev [B]Next [C]Toggle [C+]Back"); - - // Nav bar - gcDetNavBar = fcNavBarCreate(geocacheDetailsCtr, NUM_SCREENS, SCREEN_GEOCACHE); - - // === Filter sub-screen container (sub 3) (#122) === - // Minimal object count — no fcHeaderCreate, tap-to-cycle values - geocacheFilterCtr = lv_obj_create(geocacheScr); - lv_obj_remove_style_all(geocacheFilterCtr); - lv_obj_set_size(geocacheFilterCtr, SCREEN_W, SCREEN_H); - lv_obj_set_pos(geocacheFilterCtr, 0, 0); - lv_obj_set_style_bg_color(geocacheFilterCtr, FC_COLOR_BG, 0); - lv_obj_set_style_bg_opa(geocacheFilterCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(geocacheFilterCtr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); - - // Title (simple label, not full header — saves 4 objects) - lv_obj_t* filtTitle = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(filtTitle, 12, 8); - lv_obj_set_style_text_font(filtTitle, FC_FONT_MD, 0); - lv_obj_set_style_text_color(filtTitle, FC_COLOR_HEADER, 0); - lv_label_set_text(filtTitle, "FILTER CACHES"); - - // Each row: tappable label that cycles value. No separate [-][+] buttons. - // Found: tap cycles All→Unfound→Found - gcFiltLblFound = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(gcFiltLblFound, 12, 45); - lv_obj_set_style_text_font(gcFiltLblFound, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcFiltLblFound, FC_COLOR_HEADER, 0); - lv_label_set_text(gcFiltLblFound, "Found: All"); - lv_obj_add_flag(gcFiltLblFound, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFiltLblFound, 10); - lv_obj_add_event_cb(gcFiltLblFound, gcFilterFoundCb, LV_EVENT_CLICKED, NULL); - - // Sort: tap cycles Distance→Name - gcFiltLblSort = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(gcFiltLblSort, 12, 80); - lv_obj_set_style_text_font(gcFiltLblSort, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcFiltLblSort, FC_COLOR_HEADER, 0); - lv_label_set_text(gcFiltLblSort, "Sort: Distance"); - lv_obj_add_flag(gcFiltLblSort, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFiltLblSort, 10); - lv_obj_add_event_cb(gcFiltLblSort, gcFilterSortCb, LV_EVENT_CLICKED, NULL); - - // D range: tap cycles through preset ranges - gcFiltLblDRange = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(gcFiltLblDRange, 12, 115); - lv_obj_set_style_text_font(gcFiltLblDRange, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcFiltLblDRange, FC_COLOR_HEADER, 0); - lv_label_set_text(gcFiltLblDRange, "D: 1.0 - 5.0"); - lv_obj_add_flag(gcFiltLblDRange, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFiltLblDRange, 10); - lv_obj_add_event_cb(gcFiltLblDRange, gcFilterDMinUpCb, LV_EVENT_CLICKED, NULL); - - // T range: tap cycles through preset ranges - gcFiltLblTRange = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(gcFiltLblTRange, 12, 150); - lv_obj_set_style_text_font(gcFiltLblTRange, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcFiltLblTRange, FC_COLOR_HEADER, 0); - lv_label_set_text(gcFiltLblTRange, "T: 1.0 - 5.0"); - lv_obj_add_flag(gcFiltLblTRange, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFiltLblTRange, 10); - lv_obj_add_event_cb(gcFiltLblTRange, gcFilterTMinUpCb, LV_EVENT_CLICKED, NULL); - - // Distance: tap cycles All→1mi→5mi→10mi→25mi - gcFiltLblDist = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(gcFiltLblDist, 12, 185); - lv_obj_set_style_text_font(gcFiltLblDist, FC_FONT_MD, 0); - lv_obj_set_style_text_color(gcFiltLblDist, FC_COLOR_HEADER, 0); - lv_label_set_text(gcFiltLblDist, "Dist: All"); - lv_obj_add_flag(gcFiltLblDist, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(gcFiltLblDist, 10); - lv_obj_add_event_cb(gcFiltLblDist, gcFilterDistCb, LV_EVENT_CLICKED, NULL); - - // Reset + Back on same row - lv_obj_t* resetBtn = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(resetBtn, 12, 230); - lv_obj_set_style_text_font(resetBtn, FC_FONT_MD, 0); - lv_obj_set_style_text_color(resetBtn, FC_COLOR_WARN, 0); - lv_label_set_text(resetBtn, "[Reset All]"); - lv_obj_add_flag(resetBtn, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(resetBtn, 10); - lv_obj_add_event_cb(resetBtn, gcFilterResetCb, LV_EVENT_CLICKED, NULL); - - lv_obj_t* backBtn = lv_label_create(geocacheFilterCtr); - lv_obj_set_pos(backBtn, 350, 230); - lv_obj_set_style_text_font(backBtn, FC_FONT_MD, 0); - lv_obj_set_style_text_color(backBtn, FC_COLOR_HEADER, 0); - lv_label_set_text(backBtn, "[Back]"); - lv_obj_add_flag(backBtn, LV_OBJ_FLAG_CLICKABLE); - lv_obj_set_ext_click_area(backBtn, 10); - lv_obj_add_event_cb(backBtn, gcFilterBackCb, LV_EVENT_CLICKED, NULL); - - logPrintln("[LVGL] Geocache screen built (#110)"); -} - -// ============== LVGL Geocache Accuracy Color (#110) ============== - -static lv_color_t getLvglAccuracyColor(float accuracyM) { - if (accuracyM < 10.0f) return FC_COLOR_VALUE; // Green — excellent - if (accuracyM < 25.0f) return FC_COLOR_WARN; // Orange — good - return FC_COLOR_ERROR; // Red — poor -} - -// ============== LVGL Geocache Data Update (#110) ============== - -void updateGeocacheData() { - if (!geocacheScr) return; - - // Sub-screen visibility switching (#122: added sub 3 filter) - lv_obj_add_flag(geocacheNavCtr, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); - if (geocacheSubScreen == 0) lv_obj_clear_flag(geocacheNavCtr, LV_OBJ_FLAG_HIDDEN); - else if (geocacheSubScreen == 1) lv_obj_clear_flag(geocacheListCtr, LV_OBJ_FLAG_HIDDEN); - else if (geocacheSubScreen == 2) lv_obj_clear_flag(geocacheDetailsCtr, LV_OBJ_FLAG_HIDDEN); - else if (geocacheSubScreen == 3) lv_obj_clear_flag(geocacheFilterCtr, LV_OBJ_FLAG_HIDDEN); - - // === NAV sub-screen data (sub 0) === - if (geocacheSubScreen == 0) { - if (cacheListCount == 0 || !cacheList[selectedCacheIndex].valid) { - lv_label_set_text(gcNavLblName, "No cache loaded"); - lv_label_set_text(gcNavLblDist, ""); - lv_label_set_text(gcNavLblDT, ""); - lv_label_set_text(gcNavLblBearing, ""); - lv_label_set_text(gcNavLblAccuracy, ""); - lv_label_set_text(gcNavLblHint, "Upload caches via web interface"); - return; - } - - GeocacheEntry& cache = cacheList[selectedCacheIndex]; - lv_label_set_text(gcNavLblName, cache.name); - lv_label_set_text_fmt(gcNavLblDT, "D:%.1f T:%.1f", cache.difficulty, cache.terrain); - - if (gpsData.valid) { - float distKm = calcDistanceKm(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - float distM = distKm * 1000.0f; - float bearing = calcBearing(gpsData.latitude, gpsData.longitude, - cache.latitude, cache.longitude); - float accuracyM = getGpsAccuracyMeters(); - bool inSearchZone = (distM < accuracyM); - - // Distance - if (inSearchZone) { - lv_label_set_text(gcNavLblDist, "SEARCH ZONE"); - lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_WARN, 0); - } else if (useMetricUnits) { - if (distKm >= 1.0f) lv_label_set_text_fmt(gcNavLblDist, "%.1f km", distKm); - else lv_label_set_text_fmt(gcNavLblDist, "%d m", (int)distM); - lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); - } else { - float distMi = distKm * 0.621371f; - float distFt = distM * 3.28084f; - if (distMi >= 0.1f) lv_label_set_text_fmt(gcNavLblDist, "%.1f mi", distMi); - else lv_label_set_text_fmt(gcNavLblDist, "%d ft", (int)distFt); - lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_VALUE, 0); - } - - // Bearing - lv_label_set_text_fmt(gcNavLblBearing, "%d\xC2\xB0", (int)bearing); - - // Accuracy (color-coded) - if (useMetricUnits) { - lv_label_set_text_fmt(gcNavLblAccuracy, "+/-%dm", (int)accuracyM); - } else { - lv_label_set_text_fmt(gcNavLblAccuracy, "+/-%dft", (int)(accuracyM * 3.28084f)); - } - lv_obj_set_style_text_color(gcNavLblAccuracy, getLvglAccuracyColor(accuracyM), 0); - - // Hint (full in search zone, truncated otherwise) - if (inSearchZone) { - lv_label_set_text(gcNavLblHint, cache.hint); - } else { - char hintPreview[32]; - strncpy(hintPreview, cache.hint, 30); - hintPreview[30] = '\0'; - if (strlen(cache.hint) > 30) strcat(hintPreview, ".."); - lv_label_set_text(gcNavLblHint, hintPreview); - } - - // Invalidate nav graphic on bearing/heading change (2° threshold) - float bDiff = fabs(bearing - gcNavLastBearing); - float hDiff = fabs(imuData.heading - gcNavLastHeading); - if (bDiff > 180) bDiff = 360 - bDiff; - if (hDiff > 180) hDiff = 360 - hDiff; - if (bDiff >= 2.0f || hDiff >= 2.0f || inSearchZone != gcNavLastInZone) { - gcNavLastBearing = bearing; - gcNavLastHeading = imuData.heading; - gcNavLastInZone = inSearchZone; - lv_obj_invalidate(gcNavGraphicObj); - } - } else { - lv_label_set_text(gcNavLblDist, "Acquiring GPS..."); - lv_obj_set_style_text_color(gcNavLblDist, FC_COLOR_WARN, 0); - lv_label_set_text(gcNavLblDT, ""); // Clear to prevent overlap with dist text - lv_label_set_text(gcNavLblBearing, ""); - lv_label_set_text(gcNavLblAccuracy, ""); - } - fcNavBarSetActive(gcNavNavBar, currentScreen); - } - - // === LIST sub-screen data (sub 1) === - if (geocacheSubScreen == 1) { - // Periodic re-sort every 30s when GPS valid (#122) - if (gpsData.valid && cacheListCount > 0 && millis() - gcLastSortTime > 30000) { - gcApplyFilters(); - } - - // Clamp highlight to filtered range - if (gcFilteredCount > 0 && listHighlightIndex >= gcFilteredCount) - listHighlightIndex = gcFilteredCount - 1; - - // Count label: show filtered/total - if (gcFilteredCount < cacheListCount) - lv_label_set_text_fmt(gcListLblCount, "[%d/%d shown]", gcFilteredCount, cacheListCount); - else - lv_label_set_text_fmt(gcListLblCount, "[%d/%d]", listHighlightIndex + 1, cacheListCount); - - // Active filter indicator (#122) - bool hasFilter = (gcFilterFoundMode != 0 || gcFilterDMin > 1.0f || gcFilterDMax < 5.0f || - gcFilterTMin > 1.0f || gcFilterTMax < 5.0f || gcFilterMaxDistKm > 0 || - gcSortMode != 0); - lv_label_set_text(gcFiltLblActive, hasFilter ? "[FILTERED]" : ""); - - for (int r = 0; r < MAX_CACHES; r++) { - if (r >= gcFilteredCount) { - lv_obj_add_flag(gcListRows[r], LV_OBJ_FLAG_HIDDEN); - continue; - } - lv_obj_clear_flag(gcListRows[r], LV_OBJ_FLAG_HIDDEN); - int ci = gcFilteredIndices[r]; - GeocacheEntry& c = cacheList[ci]; - - // Highlight row background - if (r == listHighlightIndex) { - lv_obj_set_style_bg_color(gcListRows[r], lv_color_hex(0x1A1A2E), 0); - lv_obj_set_style_bg_opa(gcListRows[r], LV_OPA_COVER, 0); - lv_obj_set_style_text_color(gcListRowLabel[r], FC_COLOR_HEADER, 0); - } else { - lv_obj_set_style_bg_opa(gcListRows[r], LV_OPA_TRANSP, 0); - lv_obj_set_style_text_color(gcListRowLabel[r], FC_COLOR_TEXT, 0); - } - - // Format single row string: "> 2.3mi great white * D:2 T:2" - char distBuf[10]; - if (gpsData.valid) { - float dk = gcCachedDist[ci]; - if (useMetricUnits) { - if (dk >= 1.0f) snprintf(distBuf, sizeof(distBuf), "%.1fkm", dk); - else snprintf(distBuf, sizeof(distBuf), "%dm", (int)(dk * 1000)); - } else { - float mi = dk * 0.621371f; - if (mi >= 0.1f) snprintf(distBuf, sizeof(distBuf), "%.1fmi", mi); - else snprintf(distBuf, sizeof(distBuf), "%dft", (int)(dk * 3280.84f)); - } - } else { - strcpy(distBuf, "--"); - } - char nameBuf[20]; - strncpy(nameBuf, c.name, 16); - nameBuf[16] = '\0'; - if (strlen(c.name) > 16) { nameBuf[14] = '.'; nameBuf[15] = '.'; nameBuf[16] = '\0'; } - - char rowBuf[64]; - snprintf(rowBuf, sizeof(rowBuf), "%c %-7s %-16s %c D:%d T:%d", - (r == listHighlightIndex) ? '>' : ' ', - distBuf, nameBuf, - c.found ? '*' : ' ', - (int)c.difficulty, (int)c.terrain); - lv_label_set_text(gcListRowLabel[r], rowBuf); - } - - // Scroll highlighted row into view - if (listHighlightIndex < gcFilteredCount) { - lv_obj_scroll_to_view(gcListRows[listHighlightIndex], LV_ANIM_ON); - } - fcNavBarSetActive(gcListNavBar, currentScreen); - } - - // === DETAILS sub-screen data (sub 2) === - if (geocacheSubScreen == 2) { - if (gcFilteredCount == 0 || listHighlightIndex >= gcFilteredCount) return; - int ci = gcFilteredIndices[listHighlightIndex]; - GeocacheEntry& c = cacheList[ci]; - - lv_label_set_text_fmt(gcDetLblCount, "[%d/%d]", listHighlightIndex + 1, gcFilteredCount); - lv_label_set_text(gcDetLblName, c.name); - lv_label_set_text(gcDetLblGC, c.gcCode); - lv_label_set_text_fmt(gcDetLblCoords, "%.4f%c %.4f%c", - fabs(c.latitude), c.latitude >= 0 ? 'N' : 'S', - fabs(c.longitude), c.longitude >= 0 ? 'E' : 'W'); - lv_label_set_text_fmt(gcDetLblDT, "Difficulty: %.1f Terrain: %.1f", - c.difficulty, c.terrain); - - // Dynamic distance - if (gpsData.valid) { - float dk = calcDistanceKm(gpsData.latitude, gpsData.longitude, - c.latitude, c.longitude); - float bearing = calcBearing(gpsData.latitude, gpsData.longitude, - c.latitude, c.longitude); - if (useMetricUnits) { - lv_label_set_text_fmt(gcDetLblDist, "%.2f km Bearing: %d\xC2\xB0", dk, (int)bearing); - } else { - lv_label_set_text_fmt(gcDetLblDist, "%.2f mi Bearing: %d\xC2\xB0", - dk * 0.621371f, (int)bearing); - } - } else { - lv_label_set_text(gcDetLblDist, "GPS not available"); - } - - lv_label_set_text(gcDetLblHint, c.hint); - - // Found status (color-coded) - if (c.found) { - lv_label_set_text(gcDetLblFound, "[* FOUND]"); - lv_obj_set_style_text_color(gcDetLblFound, FC_COLOR_VALUE, 0); - } else { - lv_label_set_text(gcDetLblFound, "[ NOT FOUND ]"); - lv_obj_set_style_text_color(gcDetLblFound, FC_COLOR_DIM, 0); - } - fcNavBarSetActive(gcDetNavBar, currentScreen); - } -} - -// ============== LVGL Environment Screen Builder (#111) ============== - -void buildEnvScreen() { - // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() - envScr = lv_obj_create(NULL); - lv_obj_set_size(envScr, 480, 320); - lv_obj_set_style_bg_color(envScr, FC_COLOR_BG, 0); - lv_obj_set_style_border_width(envScr, 0, 0); - lv_obj_set_style_radius(envScr, 0, 0); - lv_obj_set_style_pad_all(envScr, 0, 0); - lv_obj_clear_flag(envScr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(envScr, screenGestureCb, LV_EVENT_GESTURE, NULL); - - // Header - envHeader = fcHeaderCreate(envScr, "ENVIRONMENT"); - - // Helper lambda for label pairs - int labelX = 10; - int valueX = 80; - int y = 42; - int lineH = 28; - - auto makeLabelPair = [&](lv_obj_t** lblOut, lv_obj_t** valOut, - const char* labelText, int row) { - int rowY = y + row * lineH; - - *lblOut = lv_label_create(envScr); - lv_label_set_text(*lblOut, labelText); - lv_obj_set_pos(*lblOut, labelX, rowY); - lv_obj_set_style_text_font(*lblOut, FC_FONT_SM, 0); - lv_obj_set_style_text_color(*lblOut, FC_COLOR_DIM, 0); - - *valOut = lv_label_create(envScr); - lv_label_set_text(*valOut, "---"); - lv_obj_set_pos(*valOut, valueX, rowY); - lv_obj_set_style_text_font(*valOut, FC_FONT_MD, 0); - lv_obj_set_style_text_color(*valOut, FC_COLOR_VALUE, 0); - }; - - makeLabelPair(&envLblTempLabel, &envLblTempValue, "Temp:", 0); - makeLabelPair(&envLblHumidLabel, &envLblHumidValue, "Humid:", 1); - makeLabelPair(&envLblIaqLabel, &envLblIaqValue, "IAQ:", 2); - makeLabelPair(&envLblCo2Label, &envLblCo2Value, "CO2:", 3); - makeLabelPair(&envLblPressLabel, &envLblPressValue, "Press:", 4); - makeLabelPair(&envLblFcstLabel, &envLblFcstValue, "Fcst:", 5); - - // Error label (hidden unless no sensors at all) - envLblNoSensors = lv_label_create(envScr); - lv_label_set_text(envLblNoSensors, "No env sensors"); - lv_obj_set_style_text_font(envLblNoSensors, FC_FONT_LG, 0); - lv_obj_set_style_text_color(envLblNoSensors, FC_COLOR_ERROR, 0); - lv_obj_center(envLblNoSensors); - lv_obj_add_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); - - // NavBar - envNavBar = fcNavBarCreate(envScr, NUM_SCREENS, SCREEN_ENV); -} - -// ============== LVGL Telemetry Screen Builder (#111) ============== - -void buildTelemetryScreen() { - // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() - telemetryScr = lv_obj_create(NULL); - lv_obj_set_size(telemetryScr, 480, 320); - lv_obj_set_style_bg_color(telemetryScr, FC_COLOR_BG, 0); - lv_obj_set_style_border_width(telemetryScr, 0, 0); - lv_obj_set_style_radius(telemetryScr, 0, 0); - lv_obj_set_style_pad_all(telemetryScr, 0, 0); - lv_obj_clear_flag(telemetryScr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(telemetryScr, screenGestureCb, LV_EVENT_GESTURE, NULL); - - // Header - telHeader = fcHeaderCreate(telemetryScr, "TELEMETRY"); - - // Column geometry - int leftLabelX = 20; - int leftValueX = 110; - int rightLabelX = 250; - int rightValueX = 350; - int lineH = 28; - - // Helper lambda for label creation - auto makeLabel = [&](lv_obj_t** out, const char* text, int x, int yPos, - const lv_font_t* font, lv_color_t color) { - *out = lv_label_create(telemetryScr); - lv_label_set_text(*out, text); - lv_obj_set_pos(*out, x, yPos); - lv_obj_set_style_text_font(*out, font, 0); - lv_obj_set_style_text_color(*out, color, 0); - }; - - // === GPS Section === - telLblGpsSection = lv_label_create(telemetryScr); - lv_label_set_text(telLblGpsSection, "GPS"); - lv_obj_set_style_text_font(telLblGpsSection, FC_FONT_SM, 0); - lv_obj_set_style_text_color(telLblGpsSection, FC_COLOR_HEADER, 0); - lv_obj_set_width(telLblGpsSection, 480); - lv_obj_set_style_text_align(telLblGpsSection, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_pos(telLblGpsSection, 0, 36); - - // GPS data rows (y starts at 56) - int gy = 56; - - // Row 1: Lat / Lon - makeLabel(&telLblLatLabel, "Lat:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblLatValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - makeLabel(&telLblLonLabel, "Lon:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblLonValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - gy += lineH; - - // Row 2: Alt / Spd - makeLabel(&telLblAltLabel, "Alt:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblAltValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - makeLabel(&telLblSpdLabel, "Spd:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblSpdValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - gy += lineH; - - // Row 3: Sat / HDOP - makeLabel(&telLblSatLabel, "Sat:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblSatValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - makeLabel(&telLblHdopLabel, "HDOP:", rightLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblHdopValue, "---", rightValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - gy += lineH; - - // Row 4: Status (full width) - makeLabel(&telLblStatusLabel, "Status:", leftLabelX, gy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblStatusValue, "---", leftValueX, gy, FC_FONT_MD, FC_COLOR_VALUE); - - // GPS acquiring state labels (hidden by default) - makeLabel(&telLblGpsAcquiring, "Acquiring fix...", 60, 70, FC_FONT_LG, FC_COLOR_WARN); - lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); - - makeLabel(&telLblGpsElapsed, "Elapsed: 0m 0s", 60, 100, FC_FONT_LG, FC_COLOR_DIM); - lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); - - makeLabel(&telLblGpsSkyHint, "Need clear sky view", 60, 130, FC_FONT_MD, FC_COLOR_DIM); - lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); - - makeLabel(&telLblGpsSatCount, "Sats: 0", 60, 147, FC_FONT_LG, FC_COLOR_VALUE); - lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); - - // GPS no-data state labels (hidden by default) - makeLabel(&telLblGpsNoData, "No GPS data", 80, 80, FC_FONT_LG, FC_COLOR_ERROR); - lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); - - makeLabel(&telLblGpsCheckConn, "Check connection", 60, 116, FC_FONT_LG, FC_COLOR_DIM); - lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); - - // === Divider === - telDivider = lv_obj_create(telemetryScr); - lv_obj_set_size(telDivider, 460, 1); - lv_obj_set_pos(telDivider, 10, 172); - lv_obj_set_style_bg_color(telDivider, FC_COLOR_DIM, 0); - lv_obj_set_style_bg_opa(telDivider, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(telDivider, 0, 0); - lv_obj_set_style_radius(telDivider, 0, 0); - lv_obj_set_style_pad_all(telDivider, 0, 0); - lv_obj_clear_flag(telDivider, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); - - // === IMU Section === - telLblImuSection = lv_label_create(telemetryScr); - lv_label_set_text(telLblImuSection, "IMU"); - lv_obj_set_style_text_font(telLblImuSection, FC_FONT_SM, 0); - lv_obj_set_style_text_color(telLblImuSection, FC_COLOR_HEADER, 0); - lv_obj_set_width(telLblImuSection, 480); - lv_obj_set_style_text_align(telLblImuSection, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_pos(telLblImuSection, 0, 178); - - int iy = 198; - - // Row 5: Heading / Roll - makeLabel(&telLblHdgLabel, "Hdg:", leftLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblHdgValue, "---", leftValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); - makeLabel(&telLblRollLabel, "Roll:", rightLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblRollValue, "---", rightValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); - iy += lineH; - - // Row 6: Pitch / Accel - makeLabel(&telLblPitchLabel, "Pitch:", leftLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblPitchValue, "---", leftValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); - makeLabel(&telLblAccelLabel, "Accel:", rightLabelX, iy, FC_FONT_SM, FC_COLOR_DIM); - makeLabel(&telLblAccelValue, "---", rightValueX, iy, FC_FONT_MD, FC_COLOR_VALUE); - - // IMU not available label (hidden by default) - makeLabel(&telLblNoImu, "IMU not available", 60, 210, FC_FONT_LG, FC_COLOR_ERROR); - lv_obj_add_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); - - // NavBar - telNavBar = fcNavBarCreate(telemetryScr, NUM_SCREENS, SCREEN_TELEMETRY); -} - -// ============== LVGL Environment Data Update (#111) ============== - -void updateEnvData() { - if (!envScr) return; - char buf[80]; - - fcNavBarSetActive(envNavBar, currentScreen); - - if (!bmeAvailable && !shtAvailable) { - // No sensors at all — show error, hide all rows - lv_obj_clear_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblTempLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblTempValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblHumidLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblHumidValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); - return; - } - - // Sensors available — hide error, show temp+humid rows - lv_obj_add_flag(envLblNoSensors, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblTempLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblTempValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblHumidLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblHumidValue, LV_OBJ_FLAG_HIDDEN); - - float tempC = shtAvailable ? shtData.temperature : envData.temperature; - float tempF = tempC * 9.0 / 5.0 + 32.0; - const char* tempSrc = shtAvailable ? "SHT" : "BME"; - - // Temp value (respects useFahrenheit) — UTF-8 degree sign \xC2\xB0 - if (useFahrenheit) - snprintf(buf, sizeof(buf), "%.1f\xC2\xB0""F (%.1fC) %s", tempF, tempC, tempSrc); - else - snprintf(buf, sizeof(buf), "%.1f\xC2\xB0""C (%.1fF) %s", tempC, tempF, tempSrc); - lv_label_set_text(envLblTempValue, buf); - - // Humidity - float humid = shtAvailable ? shtData.humidity : envData.humidity; - snprintf(buf, sizeof(buf), "%.1f%% %s", humid, tempSrc); - lv_label_set_text(envLblHumidValue, buf); - - if (bmeAvailable) { - // Show BME-only rows - lv_obj_clear_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); - - // IAQ with quality word and color coding (#31) - if (envData.iaqAccuracy == 0) - snprintf(buf, sizeof(buf), "-- [INIT]"); - else - snprintf(buf, sizeof(buf), "%.0f %s [%s]", envData.iaq, getIaqQualityText(envData.iaq), getIaqAccuracyText(envData.iaqAccuracy)); - lv_label_set_text(envLblIaqValue, buf); - if (envData.iaq > 200) - lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_ERROR, 0); - else if (envData.iaq > 100) - lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_WARN, 0); - else - lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_VALUE, 0); - - // CO2 with color coding - snprintf(buf, sizeof(buf), "%.0f ppm", envData.co2Equivalent); - lv_label_set_text(envLblCo2Value, buf); - if (envData.co2Equivalent > 2000) - lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_ERROR, 0); - else if (envData.co2Equivalent > 1000) - lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_WARN, 0); - else - lv_obj_set_style_text_color(envLblCo2Value, FC_COLOR_VALUE, 0); - - // Pressure - snprintf(buf, sizeof(buf), "%.1f hPa (%.2f\")", envData.pressure, hPaToInHg(envData.pressure)); - lv_label_set_text(envLblPressValue, buf); - - // Forecast with color coding - snprintf(buf, sizeof(buf), "%s %s", getTrendArrow(), weatherTrend.forecast); - lv_label_set_text(envLblFcstValue, buf); - if (strstr(weatherTrend.forecast, "Storm")) - lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_ERROR, 0); - else if (strstr(weatherTrend.forecast, "Rain") || strstr(weatherTrend.forecast, "Snow")) - lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_WARN, 0); - else - lv_obj_set_style_text_color(envLblFcstValue, FC_COLOR_VALUE, 0); - - } else { - // No BME — show N/A for BME-only rows - lv_obj_clear_flag(envLblIaqLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblIaqValue, LV_OBJ_FLAG_HIDDEN); - lv_label_set_text(envLblIaqValue, "N/A (no BME688)"); - lv_obj_set_style_text_color(envLblIaqValue, FC_COLOR_DIM, 0); - - lv_obj_clear_flag(envLblPressLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(envLblPressValue, LV_OBJ_FLAG_HIDDEN); - lv_label_set_text(envLblPressValue, "N/A (no BME688)"); - lv_obj_set_style_text_color(envLblPressValue, FC_COLOR_DIM, 0); - - // Hide CO2 and Forecast when no BME - lv_obj_add_flag(envLblCo2Label, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblCo2Value, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblFcstLabel, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(envLblFcstValue, LV_OBJ_FLAG_HIDDEN); - } -} - -// ============== LVGL Telemetry Data Update (#111) ============== - -// Helper: show or hide GPS data row labels -static void telShowGpsDataRows(bool show) { - lv_obj_t* gpsLabels[] = { - telLblLatLabel, telLblLatValue, telLblLonLabel, telLblLonValue, - telLblAltLabel, telLblAltValue, telLblSpdLabel, telLblSpdValue, - telLblSatLabel, telLblSatValue, telLblHdopLabel, telLblHdopValue, - telLblStatusLabel, telLblStatusValue - }; - for (auto lbl : gpsLabels) { - if (show) - lv_obj_clear_flag(lbl, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); - } -} - -static void telShowImuDataRows(bool show) { - lv_obj_t* imuLabels[] = { - telLblHdgLabel, telLblHdgValue, telLblRollLabel, telLblRollValue, - telLblPitchLabel, telLblPitchValue, telLblAccelLabel, telLblAccelValue - }; - for (auto lbl : imuLabels) { - if (show) - lv_obj_clear_flag(lbl, LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(lbl, LV_OBJ_FLAG_HIDDEN); - } -} - -void updateTelemetryData() { - if (!telemetryScr) return; - char buf[80]; - - fcNavBarSetActive(telNavBar, currentScreen); - - // === GPS Section === - if (gpsData.valid) { - // Show data rows, hide acquiring/error labels - telShowGpsDataRows(true); - lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); - - // Lat - snprintf(buf, sizeof(buf), "%.6f %c", fabs(gpsData.latitude), - gpsData.latitude >= 0 ? 'N' : 'S'); - lv_label_set_text(telLblLatValue, buf); - - // Lon - snprintf(buf, sizeof(buf), "%.6f %c", fabs(gpsData.longitude), - gpsData.longitude >= 0 ? 'E' : 'W'); - lv_label_set_text(telLblLonValue, buf); - - // Alt (respects useMetricUnits) - float alt = useMetricUnits ? gpsData.altitude : gpsData.altitude * 3.28084; - snprintf(buf, sizeof(buf), "%.1f %s", alt, useMetricUnits ? "m" : "ft"); - lv_label_set_text(telLblAltValue, buf); - - // Speed - float speed = gpsData.speedKnots * (useMetricUnits ? 1.852 : 1.15078); - snprintf(buf, sizeof(buf), "%.1f %s", speed, useMetricUnits ? "km/h" : "mph"); - lv_label_set_text(telLblSpdValue, buf); - - // Satellites - snprintf(buf, sizeof(buf), "%d", gpsData.satellites); - lv_label_set_text(telLblSatValue, buf); - - // HDOP - snprintf(buf, sizeof(buf), "%.1f", gpsData.hdop); - lv_label_set_text(telLblHdopValue, buf); - - // Status - if (gpsHadFirstFix) - snprintf(buf, sizeof(buf), "Fix OK (TTFF %lus)", gpsFirstFixTime / 1000); - else - strcpy(buf, "Fix OK"); - lv_label_set_text(telLblStatusValue, buf); - - } else if (gpsData.receiving) { - // Acquiring — hide data rows, show acquiring labels - telShowGpsDataRows(false); - lv_obj_add_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); - - lv_obj_clear_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); - - unsigned long elapsed; - if (gpsHadFirstFix && gpsSignalLostTime > 0) { - // Reacquiring after signal loss - elapsed = (millis() - gpsSignalLostTime) / 1000; - snprintf(buf, sizeof(buf), "Reacquire: %lum %lus", elapsed / 60, elapsed % 60); - } else { - // Never had fix — time since first NMEA data - elapsed = (millis() - gpsFirstReceiveTime) / 1000; - snprintf(buf, sizeof(buf), "Elapsed: %lum %lus", elapsed / 60, elapsed % 60); - } - lv_label_set_text(telLblGpsElapsed, buf); - - snprintf(buf, sizeof(buf), "Sats: %d", gpsData.satellites); - lv_label_set_text(telLblGpsSatCount, buf); - - } else { - // No GPS — hide data rows + acquiring, show error - telShowGpsDataRows(false); - lv_obj_add_flag(telLblGpsAcquiring, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsElapsed, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsSkyHint, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(telLblGpsSatCount, LV_OBJ_FLAG_HIDDEN); - - lv_obj_clear_flag(telLblGpsNoData, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(telLblGpsCheckConn, LV_OBJ_FLAG_HIDDEN); - } - - // === IMU Section === - if (imuAvailable && magAvailable) { - telShowImuDataRows(true); - lv_obj_add_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); - - snprintf(buf, sizeof(buf), "%.0f %s", imuData.heading, getCardinal(imuData.heading)); - lv_label_set_text(telLblHdgValue, buf); - - snprintf(buf, sizeof(buf), "%.0f deg", imuData.roll); - lv_label_set_text(telLblRollValue, buf); - - snprintf(buf, sizeof(buf), "%.0f deg", imuData.pitch); - lv_label_set_text(telLblPitchValue, buf); - - snprintf(buf, sizeof(buf), "%.2f m/s2", imuData.accelMag); - lv_label_set_text(telLblAccelValue, buf); - - } else { - telShowImuDataRows(false); - lv_obj_clear_flag(telLblNoImu, LV_OBJ_FLAG_HIDDEN); - } -} - -// ============== LVGL Settings Screen Functions (#112) ============== - -// LVGL heap diagnostic — log memory state on Settings interactions (#119) -static void logLvglHeap(const char* context) { - lv_mem_monitor_t mon; - lv_mem_monitor(&mon); - logPrintf("[LVGL/MEM] %s: free=%lu used=%d%% frag=%d%%\n", - context, (unsigned long)mon.free_size, - mon.used_pct, mon.frag_pct); - // Warn if dangerously low - if (mon.free_size < 8192) { - logPrintf("[LVGL/MEM] WARNING: <8KB free! Alloc may fail.\n"); - } -} - -// Settings menu button click — navigate to sub-screen -static void settingsMenuBtnCb(lv_event_t* e) { - lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e); - int subScreen = (int)(intptr_t)lv_obj_get_user_data(btn); - settingsSubScreen = subScreen; - logPrintf("[SETTINGS/LVGL] Menu → sub-screen %d\n", subScreen); - logLvglHeap("menu→sub"); // Track heap on every navigation (#119) -} - -// Settings Back button — return to previous screen -static void settingsBackToScreenCb(lv_event_t* e) { - (void)e; - logLvglHeap("settings→exit"); // Log heap BEFORE save+exit (#119) - navigateFromSettings(); -} - -// --- Configuration sub-screen callbacks (#112) --- - -// Timezone picker selection — fired by fcListPickerOpen via LV_EVENT_VALUE_CHANGED on cfgTzDropdown -static void cfgTzSelectedCb(lv_event_t* e) { - int idx = (int)(intptr_t)lv_event_get_param(e); - if (idx < 0 || idx >= TZ_PRESET_COUNT) return; - tzSelectedIndex = idx; - strncpy(posixTZ, tzPresets[idx].posix, sizeof(posixTZ) - 1); - strncpy(tzDisplayName, tzPresets[idx].name, sizeof(tzDisplayName) - 1); - fcDropdownSetValue(cfgTzDropdown, idx, tzDisplayName); - logPrintf("[CONFIG/LVGL] TZ selected: %s\n", tzDisplayName); -} - -// Timezone dropdown click — opens list picker for TZ selection (#117/#119) -static void cfgTzDropdownCb(lv_event_t* e) { - (void)e; - logPrintf("[CONFIG/LVGL] TZ dropdown CLICKED — opening picker\n"); - static const char* tzNames[TZ_PRESET_COUNT]; - for (int i = 0; i < TZ_PRESET_COUNT; i++) { - tzNames[i] = tzPresets[i].name; - } - lv_obj_t* picker = fcListPickerOpen("Time Zone", tzNames, TZ_PRESET_COUNT, - tzSelectedIndex, cfgTzDropdown); - if (!picker) { - logPrintf("[CONFIG/LVGL] TZ picker FAILED to open (OOM?)\n"); - } -} - -// Toggle change — update globals immediately for live preview -static void cfgToggleCb(lv_event_t* e) { - (void)e; - use12Hour = (fcToggleGetValue(cfgTimeToggle) == 0); // 0=12Hour - useFahrenheit = (fcToggleGetValue(cfgTempToggle) == 0); // 0=degF - useMetricUnits = (fcToggleGetValue(cfgDistToggle) == 1); // 1=Metric -} - -// Config Back — discard changes, reload from SD -static void cfgBackCb(lv_event_t* e) { - (void)e; - logLvglHeap("cfg←back"); // (#119) - loadSettings(); // Discard changes, reload from SD - // Reset toggle visuals to match reloaded values - fcToggleSetValue(cfgTimeToggle, use12Hour ? 0 : 1); - fcToggleSetValue(cfgTempToggle, useFahrenheit ? 0 : 1); - fcToggleSetValue(cfgDistToggle, useMetricUnits ? 1 : 0); - fcDropdownSetValue(cfgTzDropdown, tzSelectedIndex, tzDisplayName); - settingsSubScreen = 0; -} - -// Config OK — apply and save -static void cfgOKCb(lv_event_t* e) { - (void)e; - logLvglHeap("cfg←ok"); // (#119) - applyTimezone(); - saveSettings(); - settingsSubScreen = 0; -} - -// Display sub-screen callbacks (#112) -static void dispBrightnessChangedCb(lv_event_t* e) { - lv_obj_t* slider = (lv_obj_t*)lv_event_get_target(e); - int val = lv_slider_get_value(slider); - tftBrightness = (uint8_t)val; - analogWrite(TFT_BL, tftBrightness); - if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", val); -} - -static void dispTftDropdownCb(lv_event_t* e) { - (void)e; - logPrintf("[DISPLAY/LVGL] TFT dropdown CLICKED\n"); - int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); - fcListPickerOpen("TFT Sleep", tftTimeoutLabels, TFT_TIMEOUT_COUNT, idx, dispTftDropdown); -} - -static void dispTftSelectedCb(lv_event_t* e) { - int idx = (int)(intptr_t)lv_event_get_param(e); - if (idx < 0 || idx >= TFT_TIMEOUT_COUNT) return; - tftSleepMs = tftTimeoutPresets[idx]; - fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); - logPrintf("[DISPLAY/LVGL] TFT sleep: %s\n", tftTimeoutLabels[idx]); -} - -static void dispOledDropdownCb(lv_event_t* e) { - (void)e; - logPrintf("[DISPLAY/LVGL] OLED dropdown CLICKED\n"); - int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); - fcListPickerOpen("OLED Sleep", oledTimeoutLabels, OLED_TIMEOUT_COUNT, idx, dispOledDropdown); -} - -static void dispOledSelectedCb(lv_event_t* e) { - int idx = (int)(intptr_t)lv_event_get_param(e); - if (idx < 0 || idx >= OLED_TIMEOUT_COUNT) return; - oledSleepMs = oledTimeoutPresets[idx]; - fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); - logPrintf("[DISPLAY/LVGL] OLED sleep: %s\n", oledTimeoutLabels[idx]); -} - -static void dispBackCb(lv_event_t* e) { - (void)e; - logLvglHeap("disp←back"); // (#119) - loadSettings(); // Restore saved values - analogWrite(TFT_BL, tftBrightness); // Apply restored brightness - // Sync slider + label to restored values - if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); - if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); - // Sync dropdown labels - if (dispTftDropdown) { - int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); - fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); - } - if (dispOledDropdown) { - int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); - fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); - } - settingsSubScreen = 0; -} - -static void dispOKCb(lv_event_t* e) { - (void)e; - logLvglHeap("disp←ok"); // (#119) - saveSettings(); - settingsSubScreen = 0; -} - -// Compass Cal sub-screen callbacks (#112) -static void calStartBtnCb(lv_event_t* e) { - (void)e; - if (!magAvailable || magCalibrating) return; - magCalibrating = true; - magCalStartTime = millis(); - magCalMinX = magCalMinY = magCalMinZ = 99999; - magCalMaxX = magCalMaxY = magCalMaxZ = -99999; - logPrintln("[MAG/LVGL] Calibration started"); -} - -static void calBackCb(lv_event_t* e) { - (void)e; - if (magCalibrating) { - magCalibrating = false; - logPrintln("[MAG/LVGL] Calibration cancelled"); - } - settingsSubScreen = 0; -} - -// Diagnostics sub-screen callback (#112) -static void diagsBackCb(lv_event_t* e) { - (void)e; - settingsSubScreen = 0; -} - -// About sub-screen callback (#112) -static void aboutBackCb(lv_event_t* e) { - (void)e; - settingsSubScreen = 0; -} - -// Factory Reset sub-screen callbacks (#112) -static void resetBackCb(lv_event_t* e) { - (void)e; - settingsSubScreen = 0; -} - -static void resetBtnCb(lv_event_t* e) { - (void)e; - factoryReset(); - // Sync all LVGL widgets to restored defaults - if (cfgTimeToggle) fcToggleSetValue(cfgTimeToggle, use12Hour ? 0 : 1); - if (cfgTempToggle) fcToggleSetValue(cfgTempToggle, useFahrenheit ? 0 : 1); - if (cfgDistToggle) fcToggleSetValue(cfgDistToggle, useMetricUnits ? 1 : 0); - if (cfgTzDropdown) fcDropdownSetValue(cfgTzDropdown, tzSelectedIndex, tzDisplayName); - if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); - if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); - if (dispTftDropdown) { - int idx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); - fcDropdownSetValue(dispTftDropdown, idx, tftTimeoutLabels[idx]); - } - if (dispOledDropdown) { - int idx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); - fcDropdownSetValue(dispOledDropdown, idx, oledTimeoutLabels[idx]); - } - settingsSubScreen = 0; - logPrintln("[SETTINGS/LVGL] Factory reset executed"); -} - -// Update settings sub-screen visibility -void updateSettingsData() { - if (!settingsScr) return; - - // Only toggle container visibility when the sub-screen CHANGES (#117) - // Hiding+re-showing the active container every 500ms cancels LVGL's - // press tracking on child buttons, preventing CLICKED events. - static int prevSubScreen = -1; // force first-run update - if (settingsSubScreen != prevSubScreen) { - // Hide all containers - if (settingsMenuCtr) lv_obj_add_flag(settingsMenuCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsConfigCtr) lv_obj_add_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsDisplayCtr) lv_obj_add_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsCalCtr) lv_obj_add_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsDiagsCtr) lv_obj_add_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsAboutCtr) lv_obj_add_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); - if (settingsResetCtr) lv_obj_add_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); - - // Show the active sub-screen container and reset scroll position (#101) - switch (settingsSubScreen) { - case 0: if (settingsMenuCtr) lv_obj_clear_flag(settingsMenuCtr, LV_OBJ_FLAG_HIDDEN); break; - case 1: if (settingsConfigCtr) lv_obj_clear_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); break; - case 2: if (settingsDisplayCtr) lv_obj_clear_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); break; - case 3: if (settingsCalCtr) lv_obj_clear_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); break; - case 4: if (settingsDiagsCtr) lv_obj_clear_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); break; - case 5: if (settingsAboutCtr) lv_obj_clear_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); break; - case 6: if (settingsResetCtr) lv_obj_clear_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); break; - } - // Reset scroll to top when entering a sub-screen (#101) - if (settingsSubScreen >= 0 && settingsSubScreen < 7 && settingsScrollAreas[settingsSubScreen]) { - lv_obj_scroll_to_y(settingsScrollAreas[settingsSubScreen], 0, LV_ANIM_OFF); - } - prevSubScreen = settingsSubScreen; - logPrintf("[SETTINGS] Sub-screen changed to %d\n", settingsSubScreen); - } - - // --- Live data updates (run every 500ms regardless of visibility) --- - switch (settingsSubScreen) { - case 1: - if (settingsConfigCtr) { - // Live preview: time | temp | distance (#112) - if (cfgPreviewLabel) { - char prev[80]; - // Time preview using formatTimeStr (respects use12Hour) - struct tm timeinfo; - char timeBuf[16] = "--:--"; - if (getLocalTime(&timeinfo, 10)) - formatTimeStr(timeBuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, false); - // Temp preview - float tempC = shtAvailable ? shtData.temperature : envData.temperature; - float dispTemp = useFahrenheit ? (tempC * 9.0f / 5.0f + 32.0f) : tempC; - char tempBuf[16]; - snprintf(tempBuf, sizeof(tempBuf), "%.1f\xC2\xB0%s", dispTemp, useFahrenheit ? "F" : "C"); - // Distance preview - const char* distEx = useMetricUnits ? "1.0 km" : "0.6 mi"; - snprintf(prev, sizeof(prev), "%s | %s | %s", timeBuf, tempBuf, distEx); - lv_label_set_text(cfgPreviewLabel, prev); - } - } - break; - case 2: - if (settingsDisplayCtr) { - // Keep brightness widgets in sync if changed externally - if (dispBrightnessSlider) lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); - if (dispBrightnessLabel) lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); - } - break; - case 3: - if (settingsCalCtr) { - if (magCalibrating) { - // === ACTIVE CALIBRATION STATE === - // Hide idle widgets - if (calStatusLabel) lv_obj_add_flag(calStatusLabel, LV_OBJ_FLAG_HIDDEN); - if (calOffsetsLabel) lv_obj_add_flag(calOffsetsLabel, LV_OBJ_FLAG_HIDDEN); - if (calStartBtn) lv_obj_add_flag(calStartBtn, LV_OBJ_FLAG_HIDDEN); - if (calIdleActBar) lv_obj_add_flag(calIdleActBar, LV_OBJ_FLAG_HIDDEN); - // Show active widgets - if (calArc) lv_obj_clear_flag(calArc, LV_OBJ_FLAG_HIDDEN); - if (calCountdownLabel) lv_obj_clear_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); - if (calInstructLabel) lv_obj_clear_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); - if (calMinMaxLabel) lv_obj_clear_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); - - unsigned long elapsed = millis() - magCalStartTime; - int pct = (int)((elapsed * 100UL) / MAG_CAL_DURATION_MS); - if (pct > 100) pct = 100; - int remaining = ((int)MAG_CAL_DURATION_MS - (int)elapsed) / 1000; - if (remaining < 0) remaining = 0; - - // Update arc progress - if (calArc) lv_arc_set_value(calArc, pct); - // Update countdown - if (calCountdownLabel) lv_label_set_text_fmt(calCountdownLabel, "%d", remaining); - // Update min/max - if (calMinMaxLabel) { - char mmBuf[128]; - snprintf(mmBuf, sizeof(mmBuf), - "X: %.1f to %.1f\nY: %.1f to %.1f\nZ: %.1f to %.1f", - magCalMinX < 99998 ? magCalMinX : 0.0f, magCalMaxX > -99998 ? magCalMaxX : 0.0f, - magCalMinY < 99998 ? magCalMinY : 0.0f, magCalMaxY > -99998 ? magCalMaxY : 0.0f, - magCalMinZ < 99998 ? magCalMinZ : 0.0f, magCalMaxZ > -99998 ? magCalMaxZ : 0.0f); - lv_label_set_text(calMinMaxLabel, mmBuf); - } - - // Check completion - if (elapsed >= MAG_CAL_DURATION_MS) { - // Compute hard-iron offsets - magOffsetX = (magCalMaxX + magCalMinX) / 2.0f; - magOffsetY = (magCalMaxY + magCalMinY) / 2.0f; - magOffsetZ = (magCalMaxZ + magCalMinZ) / 2.0f; - magCalibrated = true; - magCalibrating = false; - saveMagCal(); - logPrintf("[MAG/LVGL] Cal complete: X=%.2f Y=%.2f Z=%.2f\n", magOffsetX, magOffsetY, magOffsetZ); - // Show completion briefly — idle widgets will show on next frame - } - - } else { - // === IDLE STATE === - // Show idle widgets - if (calStatusLabel) lv_obj_clear_flag(calStatusLabel, LV_OBJ_FLAG_HIDDEN); - if (calOffsetsLabel) lv_obj_clear_flag(calOffsetsLabel, LV_OBJ_FLAG_HIDDEN); - if (calStartBtn) lv_obj_clear_flag(calStartBtn, LV_OBJ_FLAG_HIDDEN); - if (calIdleActBar) lv_obj_clear_flag(calIdleActBar, LV_OBJ_FLAG_HIDDEN); - - // Update start button state — magAvailable may have changed since build time (#112) - if (calStartBtn) { - if (magAvailable) { - lv_obj_clear_state(calStartBtn, LV_STATE_DISABLED); - lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x007D00), 0); - } else { - lv_obj_add_state(calStartBtn, LV_STATE_DISABLED); - lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x424242), 0); - } - } - - // Hide active widgets - if (calArc) lv_obj_add_flag(calArc, LV_OBJ_FLAG_HIDDEN); - if (calCountdownLabel) lv_obj_add_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); - if (calInstructLabel) lv_obj_add_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); - if (calMinMaxLabel) lv_obj_add_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); - - // Update status - if (calStatusLabel) { - if (magCalibrated) { - lv_label_set_text(calStatusLabel, "Status: Calibrated"); - lv_obj_set_style_text_color(calStatusLabel, FC_COLOR_VALUE, 0); // Green - } else { - lv_label_set_text(calStatusLabel, "Status: Not calibrated"); - lv_obj_set_style_text_color(calStatusLabel, FC_COLOR_DIM, 0); - } - } - // Update offsets - if (calOffsetsLabel) { - if (magCalibrated) { - char offBuf[64]; - snprintf(offBuf, sizeof(offBuf), "X: %.2f Y: %.2f Z: %.2f", magOffsetX, magOffsetY, magOffsetZ); - lv_label_set_text(calOffsetsLabel, offBuf); - lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_VALUE, 0); - } else { - lv_label_set_text(calOffsetsLabel, "Offsets: ---"); - lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_DIM, 0); - } - } - } - } - break; - case 4: - if (settingsDiagsCtr) { - char dBuf[80]; - - // [0] BSEC - snprintf(dBuf, sizeof(dBuf), "Load:%s Save:%s Acc:%s", - bsecStateLoaded ? "Y" : "N", bsecStateSaved ? "Y" : "N", - getIaqAccuracyText(envData.iaqAccuracy)); - lv_label_set_text(diagValueLabels[0], dBuf); - lv_obj_set_style_text_color(diagValueLabels[0], bsecStateLoaded ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); - - // [1] Weather - snprintf(dBuf, sizeof(dBuf), "Mem:%d Files:%d Tot:%d", - weatherHistoryCount, weatherLogFileCount, weatherLogEntryCount); - lv_label_set_text(diagValueLabels[1], dBuf); - - // [2] Heap - snprintf(dBuf, sizeof(dBuf), "%luK / %luK", - (unsigned long)(ESP.getFreeHeap() / 1024), (unsigned long)(ESP.getHeapSize() / 1024)); - lv_label_set_text(diagValueLabels[2], dBuf); - - // [3] PSRAM - if (psramFound()) { - snprintf(dBuf, sizeof(dBuf), "%luK / %luK", - (unsigned long)(ESP.getFreePsram() / 1024), (unsigned long)(ESP.getPsramSize() / 1024)); - } else { - snprintf(dBuf, sizeof(dBuf), "Not available"); - } - lv_label_set_text(diagValueLabels[3], dBuf); - lv_obj_set_style_text_color(diagValueLabels[3], psramFound() ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); - - // [4] Sensors - snprintf(dBuf, sizeof(dBuf), "BME:%s SHT:%s IMU:%s Bat:%s FRAM:%s CTP:%s", - bmeAvailable ? "Y" : "N", shtAvailable ? "Y" : "N", - imuAvailable ? "Y" : "N", batteryAvailable ? "Y" : "N", - framAvailable ? "Y" : "N", touchAvailable ? "Y" : "N"); - lv_label_set_text(diagValueLabels[4], dBuf); - - // [5] Temps - if (shtAvailable && bmeAvailable) { - float shtT = useFahrenheit ? (shtData.temperature * 9.0f / 5.0f + 32.0f) : shtData.temperature; - float bmeT = useFahrenheit ? (envData.temperature * 9.0f / 5.0f + 32.0f) : envData.temperature; - snprintf(dBuf, sizeof(dBuf), "SHT:%.1f%s BME:%.1f%s (%+.1f)", - shtT, useFahrenheit ? "F" : "C", bmeT, useFahrenheit ? "F" : "C", shtT - bmeT); - } else if (shtAvailable) { - float t = useFahrenheit ? (shtData.temperature * 9.0f / 5.0f + 32.0f) : shtData.temperature; - snprintf(dBuf, sizeof(dBuf), "SHT:%.1f%s BME:N/A", t, useFahrenheit ? "F" : "C"); - } else if (bmeAvailable) { - float t = useFahrenheit ? (envData.temperature * 9.0f / 5.0f + 32.0f) : envData.temperature; - snprintf(dBuf, sizeof(dBuf), "SHT:N/A BME:%.1f%s", t, useFahrenheit ? "F" : "C"); - } else { - snprintf(dBuf, sizeof(dBuf), "No sensors"); - } - lv_label_set_text(diagValueLabels[5], dBuf); - - // [6] GPS - if (gpsHadFirstFix) { - snprintf(dBuf, sizeof(dBuf), "Fix in %lus", gpsFirstFixTime / 1000); - lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_VALUE, 0); - } else if (gpsHadFirstReceive) { - unsigned long elapsed = millis() / 1000; - snprintf(dBuf, sizeof(dBuf), "Acquiring (%lum %lus)", elapsed / 60, elapsed % 60); - lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_WARN, 0); - } else { - snprintf(dBuf, sizeof(dBuf), "No data"); - lv_obj_set_style_text_color(diagValueLabels[6], FC_COLOR_DIM, 0); - } - lv_label_set_text(diagValueLabels[6], dBuf); - - // [7] MagCal - if (magCalibrated) { - snprintf(dBuf, sizeof(dBuf), "%.1f, %.1f, %.1f", magOffsetX, magOffsetY, magOffsetZ); - lv_obj_set_style_text_color(diagValueLabels[7], FC_COLOR_VALUE, 0); - } else { - snprintf(dBuf, sizeof(dBuf), "None"); - lv_obj_set_style_text_color(diagValueLabels[7], FC_COLOR_DIM, 0); - } - lv_label_set_text(diagValueLabels[7], dBuf); - - // [8] Storage - if (sdHealth.available) { - unsigned long ageMin = (millis() - sdHealth.lastSuccess) / 60000; - if (sdHealth.errorCount == 0) - snprintf(dBuf, sizeof(dBuf), "SD:OK %lum OLED:%s", ageMin, oledAvailable ? "Y" : "N"); - else { - snprintf(dBuf, sizeof(dBuf), "SD:WARN E:%d R:%d OLED:%s", - sdHealth.errorCount, sdHealth.reInitCount, oledAvailable ? "Y" : "N"); - lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_WARN, 0); - } - } else { - snprintf(dBuf, sizeof(dBuf), "SD:FAIL E:%d R:%d OLED:%s", - sdHealth.errorCount, sdHealth.reInitCount, oledAvailable ? "Y" : "N"); - lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_ERROR, 0); - } - if (sdHealth.available && sdHealth.errorCount == 0) - lv_obj_set_style_text_color(diagValueLabels[8], FC_COLOR_VALUE, 0); - lv_label_set_text(diagValueLabels[8], dBuf); - - // [9] Web URL - if (wifiConnected) { - snprintf(dBuf, sizeof(dBuf), "http://fieldcompass.local/"); - lv_obj_set_style_text_color(diagValueLabels[9], FC_COLOR_DIM, 0); - } else { - snprintf(dBuf, sizeof(dBuf), "Not connected"); - lv_obj_set_style_text_color(diagValueLabels[9], FC_COLOR_DIM, 0); - } - lv_label_set_text(diagValueLabels[9], dBuf); - - // [10] Touch — last press coordinates + count (for debugging #117) - if (lastTouchX >= 0) { - snprintf(dBuf, sizeof(dBuf), "(%ld,%ld) #%lu", - lastTouchX, lastTouchY, (unsigned long)touchPressCount); - } else { - snprintf(dBuf, sizeof(dBuf), "No press yet"); - } - lv_label_set_text(diagValueLabels[10], dBuf); - lv_obj_set_style_text_color(diagValueLabels[10], - lastTouchX >= 0 ? FC_COLOR_VALUE : FC_COLOR_DIM, 0); - } - break; - case 5: - if (settingsAboutCtr) { - char aBuf[64]; - - // [0] Version — static, already set in build - - // [1] Uptime - { - unsigned long uptimeSec = millis() / 1000; - int days = uptimeSec / 86400; - int hrs = (uptimeSec % 86400) / 3600; - int mins = (uptimeSec % 3600) / 60; - int secs = uptimeSec % 60; - if (days > 0) snprintf(aBuf, sizeof(aBuf), "%dd %02d:%02d:%02d", days, hrs, mins, secs); - else snprintf(aBuf, sizeof(aBuf), "%02d:%02d:%02d", hrs, mins, secs); - lv_label_set_text(aboutValueLabels[1], aBuf); - } - - // [2] Heap - snprintf(aBuf, sizeof(aBuf), "%lu / %lu KB", - (unsigned long)(ESP.getFreeHeap() / 1024), (unsigned long)(ESP.getHeapSize() / 1024)); - lv_label_set_text(aboutValueLabels[2], aBuf); - - // [3] PSRAM - snprintf(aBuf, sizeof(aBuf), "%lu / %lu KB", - (unsigned long)(ESP.getFreePsram() / 1024), (unsigned long)(ESP.getPsramSize() / 1024)); - lv_label_set_text(aboutValueLabels[3], aBuf); - - // [4] Battery - { - bool battConn = batteryAvailable && isBatteryConnected(); - if (battConn) { - float pct = battery.cellPercent(); - float v = battery.cellVoltage(); - snprintf(aBuf, sizeof(aBuf), "%.0f%% (%.2fV)", pct, v); - } else if (batteryAvailable) { - snprintf(aBuf, sizeof(aBuf), "USB Only"); - } else { - snprintf(aBuf, sizeof(aBuf), "N/A"); - } - lv_label_set_text(aboutValueLabels[4], aBuf); - } - - // [5] WiFi - { - bool wConn = (WiFi.status() == WL_CONNECTED); - if (wConn) { - snprintf(aBuf, sizeof(aBuf), "%s %s", - WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); - } else { - snprintf(aBuf, sizeof(aBuf), "Disconnected"); - } - lv_label_set_text(aboutValueLabels[5], aBuf); - } - } - break; - } -} - -void buildSettingsScreen() { - // Root screen — independent LVGL screen, loaded via lv_screen_load_anim() - settingsScr = lv_obj_create(NULL); - lv_obj_set_size(settingsScr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsScr, FC_COLOR_BG, 0); - lv_obj_set_style_border_width(settingsScr, 0, 0); - lv_obj_set_style_radius(settingsScr, 0, 0); - lv_obj_set_style_pad_all(settingsScr, 0, 0); - lv_obj_clear_flag(settingsScr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - - // Menu container (sub-screen 0) - settingsMenuCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsMenuCtr); - lv_obj_set_size(settingsMenuCtr, SCREEN_W, SCREEN_H); - lv_obj_set_pos(settingsMenuCtr, 0, 0); - lv_obj_set_style_bg_color(settingsMenuCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsMenuCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsMenuCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - - // Header (fixed at top) - fcHeaderCreate(settingsMenuCtr, "SETTINGS"); - - // Scroll area (#101) - lv_obj_t* menuScroll = fcSettingsScrollCreate(settingsMenuCtr, 0); - - // Flex container for menu buttons (inside scroll area) - lv_obj_t* menuList = lv_obj_create(menuScroll); - lv_obj_remove_style_all(menuList); - lv_obj_set_size(menuList, 460, LV_SIZE_CONTENT); - lv_obj_set_pos(menuList, 10, 5); - lv_obj_set_style_pad_row(menuList, 1, 0); - lv_obj_set_flex_flow(menuList, LV_FLEX_FLOW_COLUMN); - lv_obj_clear_flag(menuList, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - - // 6 menu buttons — 38px tall with NO ext_click_area (#117) - // Root cause: ext_click_area on adjacent buttons creates OVERLAPPING hit - // zones (16px overlap with 4px gap + 10px ext), causing LVGL to pick the - // lower button. Fix: tall buttons, minimal gap, zero ext_click_area. - // Math: 6×38 + 5×1 = 233px fits in 235px container - for (int i = 0; i < SETTINGS_MENU_COUNT; i++) { - lv_obj_t* btn = lv_button_create(menuList); - lv_obj_set_size(btn, 440, 38); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x424242), 0); - lv_obj_set_style_radius(btn, 6, 0); - // NO ext_click_area — prevents overlap with adjacent buttons - - lv_obj_t* lbl = lv_label_create(btn); - lv_label_set_text(lbl, settingsMenuItems[i]); - lv_obj_set_style_text_font(lbl, FC_FONT_MD, 0); - lv_obj_set_style_text_color(lbl, FC_COLOR_TEXT, 0); - lv_obj_center(lbl); - - lv_obj_set_user_data(btn, (void*)(intptr_t)(i + 1)); - lv_obj_add_event_cb(btn, settingsMenuBtnCb, LV_EVENT_CLICKED, NULL); - settingsMenuBtns[i] = btn; - } - - // Action bar with Back button only - lv_obj_t* actBar = fcActionBarCreate(settingsMenuCtr, true, false); - lv_obj_t* backBtn = lv_obj_get_child(actBar, 0); - lv_obj_add_event_cb(backBtn, settingsBackToScreenCb, LV_EVENT_CLICKED, NULL); - - // --- Sub-screen 1: Configuration (#112) --- - settingsConfigCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsConfigCtr); - lv_obj_set_size(settingsConfigCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsConfigCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsConfigCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsConfigCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsConfigCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsConfigCtr, "CONFIGURATION"); - - // Scroll area (#101) - lv_obj_t* cfgScroll = fcSettingsScrollCreate(settingsConfigCtr, 1); - - // Timezone dropdown — click on ENTIRE row opens picker (#117/#119) - cfgTzDropdown = fcDropdownCreate(cfgScroll, 15, "Time Zone", tzDisplayName); - lv_obj_add_event_cb(cfgTzDropdown, cfgTzDropdownCb, LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(cfgTzDropdown, cfgTzSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); - // Time format toggle: 0=12Hour(left), 1=24Hour(right) - cfgTimeToggle = fcToggleCreate(cfgScroll, 65, "Time", "12 Hour", "24 Hour", use12Hour ? 0 : 1); - lv_obj_add_event_cb(cfgTimeToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); - - // Temperature unit toggle: 0=degF(left), 1=degC(right) - cfgTempToggle = fcToggleCreate(cfgScroll, 115, "Temp", "\xC2\xB0""F", "\xC2\xB0""C", useFahrenheit ? 0 : 1); - lv_obj_add_event_cb(cfgTempToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); - - // Distance unit toggle: 0=Imperial(left), 1=Metric(right) - cfgDistToggle = fcToggleCreate(cfgScroll, 165, "Distance", "Imperial", "Metric", useMetricUnits ? 1 : 0); - lv_obj_add_event_cb(cfgDistToggle, cfgToggleCb, LV_EVENT_VALUE_CHANGED, NULL); - - // Live preview label - cfgPreviewLabel = lv_label_create(cfgScroll); - lv_obj_set_pos(cfgPreviewLabel, 20, 205); - lv_obj_set_style_text_font(cfgPreviewLabel, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(cfgPreviewLabel, lv_color_hex(0x808080), 0); - lv_label_set_text(cfgPreviewLabel, ""); - - // Action bar with Back and OK - lv_obj_t* cfgActBar = fcActionBarCreate(settingsConfigCtr, true, true); - lv_obj_t* cfgBack = lv_obj_get_child(cfgActBar, 0); - lv_obj_t* cfgOK = lv_obj_get_child(cfgActBar, 1); - lv_obj_add_event_cb(cfgBack, cfgBackCb, LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(cfgOK, cfgOKCb, LV_EVENT_CLICKED, NULL); - - // --- Sub-screen 2: Display (#112) --- - settingsDisplayCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsDisplayCtr); - lv_obj_set_size(settingsDisplayCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsDisplayCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsDisplayCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsDisplayCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsDisplayCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsDisplayCtr, "DISPLAY"); - - // Scroll area (#101) - lv_obj_t* dispScroll = fcSettingsScrollCreate(settingsDisplayCtr, 2); - - // Brightness label - lv_obj_t* brightLbl = lv_label_create(dispScroll); - lv_label_set_text(brightLbl, "Brightness"); - lv_obj_set_style_text_color(brightLbl, FC_COLOR_DIM, 0); - lv_obj_set_style_text_font(brightLbl, FC_FONT_SM, 0); - lv_obj_set_pos(brightLbl, 20, 23); - - // Brightness slider: range 25-255, initial = tftBrightness - dispBrightnessSlider = lv_slider_create(dispScroll); - lv_obj_set_size(dispBrightnessSlider, 250, 30); - lv_obj_set_pos(dispBrightnessSlider, 140, 17); - lv_slider_set_range(dispBrightnessSlider, 25, 255); - lv_slider_set_value(dispBrightnessSlider, tftBrightness, LV_ANIM_OFF); - // Style: green indicator, dark gray track, white knob - lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0x2A2A2A), 0); // track bg - lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0x007D00), LV_PART_INDICATOR); // green fill - lv_obj_set_style_bg_color(dispBrightnessSlider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); // white knob - lv_obj_set_style_pad_all(dispBrightnessSlider, 8, LV_PART_KNOB); // bigger knob touch target - lv_obj_add_event_cb(dispBrightnessSlider, dispBrightnessChangedCb, LV_EVENT_VALUE_CHANGED, NULL); - - // Brightness numeric readout (to the right of slider) - dispBrightnessLabel = lv_label_create(dispScroll); - lv_obj_set_pos(dispBrightnessLabel, 375, 23); - lv_obj_set_style_text_color(dispBrightnessLabel, FC_COLOR_VALUE, 0); - lv_obj_set_style_text_font(dispBrightnessLabel, FC_FONT_SM, 0); - lv_label_set_text_fmt(dispBrightnessLabel, "%d", tftBrightness); - - // TFT Sleep timeout dropdown — click on entire row (#117) - int tftIdx = findTimeoutIndex(tftTimeoutPresets, TFT_TIMEOUT_COUNT, tftSleepMs); - dispTftDropdown = fcDropdownCreate(dispScroll, 75, "TFT Sleep", tftTimeoutLabels[tftIdx]); - lv_obj_add_event_cb(dispTftDropdown, dispTftDropdownCb, LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(dispTftDropdown, dispTftSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); - - // OLED Sleep timeout dropdown — click on entire row (#117) - int oledIdx = findTimeoutIndex(oledTimeoutPresets, OLED_TIMEOUT_COUNT, oledSleepMs); - dispOledDropdown = fcDropdownCreate(dispScroll, 125, "OLED Sleep", oledTimeoutLabels[oledIdx]); - lv_obj_add_event_cb(dispOledDropdown, dispOledDropdownCb, LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(dispOledDropdown, dispOledSelectedCb, LV_EVENT_VALUE_CHANGED, NULL); - - // Action bar with Back + OK - lv_obj_t* dispActBar = fcActionBarCreate(settingsDisplayCtr, true, true); - lv_obj_t* dispBack = lv_obj_get_child(dispActBar, 0); - lv_obj_t* dispOK = lv_obj_get_child(dispActBar, 1); - lv_obj_add_event_cb(dispBack, dispBackCb, LV_EVENT_CLICKED, NULL); - lv_obj_add_event_cb(dispOK, dispOKCb, LV_EVENT_CLICKED, NULL); - - // --- Sub-screen 3: Compass Calibration (#112) --- - settingsCalCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsCalCtr); - lv_obj_set_size(settingsCalCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsCalCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsCalCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsCalCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsCalCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsCalCtr, "COMPASS CAL"); - - // Scroll area (#101) - lv_obj_t* calScroll = fcSettingsScrollCreate(settingsCalCtr, 3); - - // === Idle state widgets === - - // Status label - calStatusLabel = lv_label_create(calScroll); - lv_obj_set_pos(calStatusLabel, 20, 20); - lv_obj_set_style_text_font(calStatusLabel, FC_FONT_MD, 0); - lv_label_set_text(calStatusLabel, "Status: ---"); - - // Offsets label - calOffsetsLabel = lv_label_create(calScroll); - lv_obj_set_pos(calOffsetsLabel, 20, 50); - lv_obj_set_style_text_font(calOffsetsLabel, FC_FONT_SM, 0); - lv_obj_set_style_text_color(calOffsetsLabel, FC_COLOR_DIM, 0); - lv_label_set_text(calOffsetsLabel, "Offsets: ---"); - - // Start Calibration button - calStartBtn = lv_button_create(calScroll); - lv_obj_set_size(calStartBtn, 220, 40); - lv_obj_set_pos(calStartBtn, 130, 100); - lv_obj_set_style_radius(calStartBtn, 6, 0); - if (magAvailable) { - lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x007D00), 0); // Green - } else { - lv_obj_set_style_bg_color(calStartBtn, lv_color_hex(0x424242), 0); // Gray disabled - lv_obj_add_state(calStartBtn, LV_STATE_DISABLED); - } - lv_obj_add_event_cb(calStartBtn, calStartBtnCb, LV_EVENT_CLICKED, NULL); - lv_obj_t* calBtnLbl = lv_label_create(calStartBtn); - lv_label_set_text(calBtnLbl, "Start Calibration"); - lv_obj_set_style_text_font(calBtnLbl, FC_FONT_MD, 0); - lv_obj_set_style_text_color(calBtnLbl, FC_COLOR_TEXT, 0); - lv_obj_center(calBtnLbl); - - // Idle action bar with Back only - calIdleActBar = fcActionBarCreate(settingsCalCtr, true, false); - lv_obj_t* calBack = lv_obj_get_child(calIdleActBar, 0); - lv_obj_add_event_cb(calBack, calBackCb, LV_EVENT_CLICKED, NULL); - - // === Active calibration widgets (hidden initially, inside scroll area) === - - // Progress arc - calArc = lv_arc_create(calScroll); - lv_obj_set_size(calArc, 140, 140); - lv_obj_set_pos(calArc, 170, 25); - lv_arc_set_range(calArc, 0, 100); - lv_arc_set_value(calArc, 0); - lv_arc_set_bg_angles(calArc, 0, 360); - lv_obj_remove_style(calArc, NULL, LV_PART_KNOB); // Hide knob - lv_obj_clear_flag(calArc, LV_OBJ_FLAG_CLICKABLE); // Not interactive - lv_obj_set_style_arc_color(calArc, lv_color_hex(0x2A2A2A), LV_PART_MAIN); // Background arc - lv_obj_set_style_arc_color(calArc, lv_color_hex(0x007D00), LV_PART_INDICATOR); // Green progress - lv_obj_set_style_arc_width(calArc, 12, LV_PART_MAIN); - lv_obj_set_style_arc_width(calArc, 12, LV_PART_INDICATOR); - lv_obj_add_flag(calArc, LV_OBJ_FLAG_HIDDEN); - - // Countdown label (centered in arc) - calCountdownLabel = lv_label_create(calScroll); - lv_obj_set_pos(calCountdownLabel, 225, 75); // Centered in arc area - lv_obj_set_style_text_font(calCountdownLabel, FC_FONT_HERO, 0); // 32px - lv_obj_set_style_text_color(calCountdownLabel, FC_COLOR_TEXT, 0); - lv_label_set_text(calCountdownLabel, "15"); - lv_obj_add_flag(calCountdownLabel, LV_OBJ_FLAG_HIDDEN); - - // Instruction label - calInstructLabel = lv_label_create(calScroll); - lv_obj_set_pos(calInstructLabel, 80, 180); - lv_obj_set_style_text_font(calInstructLabel, FC_FONT_MD, 0); - lv_obj_set_style_text_color(calInstructLabel, FC_COLOR_WARN, 0); // Orange - lv_label_set_text(calInstructLabel, "Rotate device slowly 360\xC2\xB0"); - lv_obj_add_flag(calInstructLabel, LV_OBJ_FLAG_HIDDEN); - - // Min/max label - calMinMaxLabel = lv_label_create(calScroll); - lv_obj_set_pos(calMinMaxLabel, 20, 215); - lv_obj_set_style_text_font(calMinMaxLabel, FC_FONT_XS, 0); // 14px - lv_obj_set_style_text_color(calMinMaxLabel, FC_COLOR_DIM, 0); - lv_label_set_text(calMinMaxLabel, ""); - lv_obj_add_flag(calMinMaxLabel, LV_OBJ_FLAG_HIDDEN); - - // --- Sub-screen 4: Diagnostics (#112) --- - settingsDiagsCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsDiagsCtr); - lv_obj_set_size(settingsDiagsCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsDiagsCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsDiagsCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsDiagsCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsDiagsCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsDiagsCtr, "DIAGNOSTICS"); - - // Scroll area (#101) — Diagnostics has 11 rows at 21px = 231px, close to 240px limit - lv_obj_t* diagsScroll = fcSettingsScrollCreate(settingsDiagsCtr, 4); - - // 11 label-value rows - static const char* diagLabels[11] = { - "BSEC:", "Weather:", "Heap:", "PSRAM:", "Sensors:", - "Temps:", "GPS:", "MagCal:", "Storage:", "Web:", "Touch:" - }; - - int diagY = 8; - int diagLineH = 21; - for (int i = 0; i < 11; i++) { - // Cyan label - lv_obj_t* lbl = lv_label_create(diagsScroll); - lv_label_set_text(lbl, diagLabels[i]); - lv_obj_set_pos(lbl, 10, diagY + i * diagLineH); - lv_obj_set_style_text_font(lbl, FC_FONT_XS, 0); - lv_obj_set_style_text_color(lbl, FC_COLOR_HEADER, 0); - - // Value label (updated each frame) - diagValueLabels[i] = lv_label_create(diagsScroll); - lv_label_set_text(diagValueLabels[i], "---"); - lv_obj_set_pos(diagValueLabels[i], 70, diagY + i * diagLineH); - lv_obj_set_style_text_font(diagValueLabels[i], FC_FONT_XS, 0); - lv_obj_set_style_text_color(diagValueLabels[i], FC_COLOR_VALUE, 0); - } - - // Action bar with Back only - lv_obj_t* diagsActBar = fcActionBarCreate(settingsDiagsCtr, true, false); - lv_obj_t* diagsBack = lv_obj_get_child(diagsActBar, 0); - lv_obj_add_event_cb(diagsBack, diagsBackCb, LV_EVENT_CLICKED, NULL); - - // --- Sub-screen 5: About (#112) --- - settingsAboutCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsAboutCtr); - lv_obj_set_size(settingsAboutCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsAboutCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsAboutCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsAboutCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsAboutCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsAboutCtr, "ABOUT"); - - // Scroll area (#101) - lv_obj_t* aboutScroll = fcSettingsScrollCreate(settingsAboutCtr, 5); - - // 6 label-value rows - static const char* aboutLabels[6] = { - "Version:", "Uptime:", "Heap:", "PSRAM:", "Battery:", "WiFi:" - }; - - int aboutY = 20; - int aboutLineH = 30; - for (int i = 0; i < 6; i++) { - lv_obj_t* lbl = lv_label_create(aboutScroll); - lv_label_set_text(lbl, aboutLabels[i]); - lv_obj_set_pos(lbl, 20, aboutY + i * aboutLineH); - lv_obj_set_style_text_font(lbl, FC_FONT_MD, 0); - lv_obj_set_style_text_color(lbl, FC_COLOR_DIM, 0); - - aboutValueLabels[i] = lv_label_create(aboutScroll); - lv_label_set_text(aboutValueLabels[i], "---"); - lv_obj_set_pos(aboutValueLabels[i], 160, aboutY + i * aboutLineH); - lv_obj_set_style_text_font(aboutValueLabels[i], FC_FONT_MD, 0); - lv_obj_set_style_text_color(aboutValueLabels[i], FC_COLOR_VALUE, 0); - } - - // Set version (static, never changes) - lv_label_set_text(aboutValueLabels[0], FW_VERSION); - - // Action bar with Back only - lv_obj_t* aboutActBar = fcActionBarCreate(settingsAboutCtr, true, false); - lv_obj_t* aboutBack = lv_obj_get_child(aboutActBar, 0); - lv_obj_add_event_cb(aboutBack, aboutBackCb, LV_EVENT_CLICKED, NULL); - - // --- Sub-screen 6: Factory Reset (#112) --- - settingsResetCtr = lv_obj_create(settingsScr); - lv_obj_remove_style_all(settingsResetCtr); - lv_obj_set_size(settingsResetCtr, SCREEN_W, SCREEN_H); - lv_obj_set_style_bg_color(settingsResetCtr, lv_color_hex(0x000000), 0); - lv_obj_set_style_bg_opa(settingsResetCtr, LV_OPA_COVER, 0); - lv_obj_clear_flag(settingsResetCtr, (lv_obj_flag_t)(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE - | LV_OBJ_FLAG_SCROLL_CHAIN_HOR | LV_OBJ_FLAG_SCROLL_CHAIN_VER)); - lv_obj_add_flag(settingsResetCtr, LV_OBJ_FLAG_HIDDEN); - - fcHeaderCreate(settingsResetCtr, "FACTORY RESET"); - - // Scroll area (#101) - lv_obj_t* resetScroll = fcSettingsScrollCreate(settingsResetCtr, 6); - - // Warning text (orange) - lv_obj_t* resetWarn = lv_label_create(resetScroll); - lv_label_set_text(resetWarn, "Reset all settings to\nfactory defaults?"); - lv_obj_set_pos(resetWarn, 20, 60); - lv_obj_set_style_text_font(resetWarn, FC_FONT_LG, 0); // 20px - lv_obj_set_style_text_color(resetWarn, FC_COLOR_WARN, 0); // Orange - - // Info text (dim) - lv_obj_t* resetInfo = lv_label_create(resetScroll); - lv_label_set_text(resetInfo, "Compass calibration will\nbe preserved."); - lv_obj_set_pos(resetInfo, 20, 125); - lv_obj_set_style_text_font(resetInfo, FC_FONT_SM, 0); // 16px - lv_obj_set_style_text_color(resetInfo, FC_COLOR_DIM, 0); - - // Action bar with Back only - lv_obj_t* resetActBar = fcActionBarCreate(settingsResetCtr, true, false); - lv_obj_t* resetBack = lv_obj_get_child(resetActBar, 0); - lv_obj_add_event_cb(resetBack, resetBackCb, LV_EVENT_CLICKED, NULL); - - // Red "Reset" button at OK button position - lv_obj_t* resetBtn = lv_button_create(settingsResetCtr); - lv_obj_set_size(resetBtn, 100, 40); - lv_obj_set_pos(resetBtn, 360, 275); - lv_obj_set_style_bg_color(resetBtn, lv_color_hex(0xCC0000), 0); // Red - lv_obj_set_style_radius(resetBtn, 8, 0); - lv_obj_add_event_cb(resetBtn, resetBtnCb, LV_EVENT_CLICKED, NULL); - lv_obj_t* resetBtnLbl = lv_label_create(resetBtn); - lv_label_set_text(resetBtnLbl, "Reset"); - lv_obj_set_style_text_font(resetBtnLbl, FC_FONT_MD, 0); - lv_obj_set_style_text_color(resetBtnLbl, FC_COLOR_TEXT, 0); - lv_obj_center(resetBtnLbl); -} - // ============== LVGL Initialization (#105) ============== void initLVGL() { @@ -3585,173 +638,6 @@ void initSD() { // ============== LVGL Screen Navigation (#113) ============== -// Screen table for main screens (Settings is modal, not in cycle) -static lv_obj_t** const mainScreens[] = { &compassScr, &geocacheScr, &envScr, &telemetryScr }; - -void navigateScreen(int delta) { - int next = (currentScreen + delta + NUM_SCREENS) % NUM_SCREENS; - lv_scr_load_anim_t anim = (delta > 0) - ? LV_SCR_LOAD_ANIM_OVER_LEFT : LV_SCR_LOAD_ANIM_OVER_RIGHT; - lv_screen_load_anim(*mainScreens[next], anim, 80, 0, false); - currentScreen = next; - geocacheSubScreen = 0; -} - -void navigateToSettings() { - previousScreen = currentScreen; - currentScreen = SCREEN_SETTINGS; - settingsSubScreen = 0; - lv_screen_load_anim(settingsScr, LV_SCR_LOAD_ANIM_OVER_LEFT, 80, 0, false); - logPrintf("[NAV] → Settings (from screen %d)\n", previousScreen); -} - -void navigateFromSettings() { - saveSettings(); - settingsSubScreen = 0; - currentScreen = previousScreen; - lv_screen_load_anim(*mainScreens[currentScreen], LV_SCR_LOAD_ANIM_OVER_RIGHT, 80, 0, false); - logPrintf("[NAV] Settings → screen %d\n", currentScreen); -} - -// LVGL gesture callback — swipe left/right on main screens (#113) -static void screenGestureCb(lv_event_t* e) { - (void)e; - lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_active()); - if (dir == LV_DIR_LEFT) navigateScreen(1); - else if (dir == LV_DIR_RIGHT) navigateScreen(-1); -} - -// Gear icon click — navigate to Settings (#113) -static void gearIconClickCb(lv_event_t* e) { - (void)e; - if (currentScreen == SCREEN_SETTINGS) return; - navigateToSettings(); -} - -// Nav screen → list touch callback (#122) -static void gcNavListBtnCb(lv_event_t* e) { - (void)e; - geocacheSubScreen = 1; - listHighlightIndex = 0; - listScrollOffset = 0; -} - -// List row tap callback (#122): tap to highlight, tap highlighted row to select+nav -static void gcListRowTapCb(lv_event_t* e) { - int row = (int)(intptr_t)lv_event_get_user_data(e); - if (row < 0 || row >= gcFilteredCount) return; - if (row == listHighlightIndex) { - // Already highlighted — select and go to nav - selectedCacheIndex = gcFilteredIndices[row]; - geocacheSubScreen = 0; - } else { - // First tap — just highlight this row - listHighlightIndex = row; - } -} - -// ============== Geocache Filter Callbacks (#122) ============== - -// Helper: update filter screen labels to reflect current state -void gcUpdateFilterLabels() { - const char* foundLabels[] = {"All", "Unfound", "Found"}; - lv_label_set_text_fmt(gcFiltLblFound, "Found: %s", foundLabels[gcFilterFoundMode]); - lv_label_set_text_fmt(gcFiltLblSort, "Sort: %s", gcSortMode == 0 ? "Distance" : "Name"); - // D range: show as ints when whole, floats otherwise - int dMinI = (int)(gcFilterDMin * 10), dMaxI = (int)(gcFilterDMax * 10); - if (dMinI % 10 == 0 && dMaxI % 10 == 0) - lv_label_set_text_fmt(gcFiltLblDRange, "D: %d - %d", (int)gcFilterDMin, (int)gcFilterDMax); - else - lv_label_set_text_fmt(gcFiltLblDRange, "D: %.1f - %.1f", gcFilterDMin, gcFilterDMax); - int tMinI = (int)(gcFilterTMin * 10), tMaxI = (int)(gcFilterTMax * 10); - if (tMinI % 10 == 0 && tMaxI % 10 == 0) - lv_label_set_text_fmt(gcFiltLblTRange, "T: %d - %d", (int)gcFilterTMin, (int)gcFilterTMax); - else - lv_label_set_text_fmt(gcFiltLblTRange, "T: %.1f - %.1f", gcFilterTMin, gcFilterTMax); - if (gcFilterMaxDistKm <= 0) { - lv_label_set_text(gcFiltLblDist, "Dist: All"); - } else if (useMetricUnits) { - lv_label_set_text_fmt(gcFiltLblDist, "Dist: %.0fkm", gcFilterMaxDistKm); - } else { - lv_label_set_text_fmt(gcFiltLblDist, "Dist: %.0fmi", gcFilterMaxDistKm * 0.621371f); - } -} - -static void gcFilterBtnCb(lv_event_t* e) { - (void)e; - geocacheSubScreen = 3; - gcUpdateFilterLabels(); -} - -static void gcFilterBackCb(lv_event_t* e) { - (void)e; - gcApplyFilters(); - listHighlightIndex = 0; - geocacheSubScreen = 1; -} - -static void gcFilterFoundCb(lv_event_t* e) { - (void)e; - gcFilterFoundMode = (gcFilterFoundMode + 1) % 3; - gcUpdateFilterLabels(); -} - -static void gcFilterSortCb(lv_event_t* e) { - (void)e; - gcSortMode = (gcSortMode + 1) % 2; - gcUpdateFilterLabels(); -} - -// D/T range presets: {min, max} pairs — tap cycles through them -static const float gcDTPresets[][2] = { - {1.0f, 5.0f}, {1.0f, 2.0f}, {1.0f, 3.0f}, {2.0f, 4.0f}, {3.0f, 5.0f} -}; -static const int GC_DT_PRESET_COUNT = 5; -static int gcDPresetIdx = 0; -static int gcTPresetIdx = 0; - -static void gcFilterDMinUpCb(lv_event_t* e) { - (void)e; - gcDPresetIdx = (gcDPresetIdx + 1) % GC_DT_PRESET_COUNT; - gcFilterDMin = gcDTPresets[gcDPresetIdx][0]; - gcFilterDMax = gcDTPresets[gcDPresetIdx][1]; - gcUpdateFilterLabels(); -} -static void gcFilterTMinUpCb(lv_event_t* e) { - (void)e; - gcTPresetIdx = (gcTPresetIdx + 1) % GC_DT_PRESET_COUNT; - gcFilterTMin = gcDTPresets[gcTPresetIdx][0]; - gcFilterTMax = gcDTPresets[gcTPresetIdx][1]; - gcUpdateFilterLabels(); -} - -// Distance presets: 0 (all), 1.6km (1mi), 8km (5mi), 16km (10mi), 40km (25mi) -static const float gcDistPresets[] = {0, 1.60934f, 8.04672f, 16.0934f, 40.2336f}; -static const int GC_DIST_PRESET_COUNT = 5; - -static void gcFilterDistCb(lv_event_t* e) { - (void)e; - // Find current preset and cycle to next - int cur = 0; - for (int i = 0; i < GC_DIST_PRESET_COUNT; i++) { - if (fabs(gcFilterMaxDistKm - gcDistPresets[i]) < 0.1f) { cur = i; break; } - } - cur = (cur + 1) % GC_DIST_PRESET_COUNT; - gcFilterMaxDistKm = gcDistPresets[cur]; - gcUpdateFilterLabels(); -} - -static void gcFilterResetCb(lv_event_t* e) { - (void)e; - gcFilterFoundMode = 0; - gcFilterDMin = 1.0f; gcFilterDMax = 5.0f; - gcFilterTMin = 1.0f; gcFilterTMax = 5.0f; - gcFilterMaxDistKm = 0; - gcSortMode = 0; - gcDPresetIdx = 0; - gcTPresetIdx = 0; - gcUpdateFilterLabels(); -} // ============== Button Handling ============== @@ -3834,33 +720,6 @@ void handleButtons() { } } -// Handle geocache-specific button actions for list/details sub-screens -void handleGeocacheButtons(bool buttonA, bool buttonB) { - if (geocacheSubScreen == 1) { - // Cache List: A=scroll up, B=scroll down (filtered list) (#122) - if (buttonA && listHighlightIndex > 0) { - listHighlightIndex--; - if (listHighlightIndex < listScrollOffset) { - listScrollOffset = listHighlightIndex; - } - } - if (buttonB && listHighlightIndex < gcFilteredCount - 1) { - listHighlightIndex++; - if (listHighlightIndex >= listScrollOffset + 5) { - listScrollOffset = listHighlightIndex - 4; - } - } - } else if (geocacheSubScreen == 2) { - // Cache Details: A=prev cache, B=next cache (filtered) (#122) - if (buttonA && listHighlightIndex > 0) { - listHighlightIndex--; - } - if (buttonB && listHighlightIndex < gcFilteredCount - 1) { - listHighlightIndex++; - } - } -} - // handleTap removed — gear icon now uses LVGL gearIconClickCb (#113) // Button C short press handler @@ -3929,54 +788,6 @@ void handleButtonCLongPress() { // Zone helper implementations removed — LVGL handles dirty tracking (#114) -// Update SD card status indicator on all screen headers (#120) -void updateSDIndicators() { - SDIndicatorState state; - if (sdAvailable && sdHealth.consecutiveFailures == 0) { - state = SD_IND_OK; - } else if (sdAvailable) { - state = SD_IND_ERROR; // mounted but experiencing errors - } else { - state = SD_IND_MISSING; // never mounted or fully failed - } - fcHeaderSetSDStatus(compassHeader, state); - fcHeaderSetSDStatus(gcNavHeader, state); - fcHeaderSetSDStatus(envHeader, state); - fcHeaderSetSDStatus(telHeader, state); -} - -void updateDisplay() { - static unsigned long lastUpdate = 0; - - // Throttle: update data every 500ms (LVGL rendering runs independently) - if (millis() - lastUpdate < 500) return; - lastUpdate = millis(); - - // Update TFT display data (if not sleeping) - if (!tftSleeping) { - // Update SD card status indicator on all screen headers (#120) - updateSDIndicators(); - - // Update active screen data — LVGL handles rendering via lv_timer_handler() - switch (currentScreen) { - case SCREEN_COMPASS: updateCompassData(); fcNavBarSetActive(compassNavBar, currentScreen); break; - case SCREEN_GEOCACHE: updateGeocacheData(); break; - case SCREEN_ENV: updateEnvData(); break; - case SCREEN_TELEMETRY: updateTelemetryData(); break; - case SCREEN_SETTINGS: updateSettingsData(); break; - } - - // Track TFT update for health monitoring - lastTFTUpdate = millis(); - tftUpdateCount++; - } - - // Update OLED display (if available and not sleeping) - if (oledAvailable && !oledSleeping) { - updateOLED(); - } -} - // Legacy TFT_eSprite draw functions removed — all rendering via LVGL (#114) // Removed: drawHeader, drawNavBar, drawLabel, drawValue, drawScreenTelemetry, // drawScreenEnv, drawScreenCompass, drawCompassRose (legacy sprite version), diff --git a/src/web.h b/src/web.h index 60a0af8..3fea66f 100644 --- a/src/web.h +++ b/src/web.h @@ -1,6 +1,12 @@ #pragma once // web.h -- extracted from src.ino by scripts/extract_unit.py (E4). #include +#include +// WebServer.h names the unqualified `FS`. FS.h provides `using fs::FS;` unless +// FS_NO_GLOBALS is defined -- and TFT_eSPI defines it, so any unit that includes +// display.h before this header would lose the alias. src.ino included +// WebServer.h before TFT_eSPI.h, which hid this. +using fs::FS; #include #include #include From 591bc8cd3aa812d79f460e9a8850e4ac1e175085 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 19:52:43 -0400 Subject: [PATCH 4/4] refactor(#265): E4 band 4, unit 4 -- the residue, the LVGL log guard, the record src/src.ino: the stranded comments and empty banners left by twelve bands of removals are gone; what remains is setup, loop, initLVGL, initSD, scanI2C and the three button handlers, 750 lines. src/lvgl_port.cpp: the LV_USE_LOG guard around lvglLogCb restored (src.ino compiled it conditionally; the harness carries a function, not the preprocessor lines around it). docs/god-object-inventory.md section 15: the band-4 record, the #265 acceptance measured on the residue (8 functions, 6 variables, cut set 0, largest cluster 1), the two harness faults the dry runs caught, and the E4 totals: src.ino 8,742 -> 750 lines into 25 translation units and 28 headers, Flash +848 bytes traced, RAM +16, every move proven behaviour-neutral. Not flashed. The PR stays open for the hardware verification #265 requires. --- docs/god-object-inventory.md | 38 +++++++++++++++++++++++ src/lvgl_port.cpp | 2 ++ src/src.ino | 60 ++---------------------------------- 3 files changed, 43 insertions(+), 57 deletions(-) diff --git a/docs/god-object-inventory.md b/docs/god-object-inventory.md index a93b3c5..03bfb2b 100644 --- a/docs/god-object-inventory.md +++ b/docs/god-object-inventory.md @@ -471,6 +471,44 @@ src.ino 5,581 -> 4,387 lines arduino-cli SUCCESS 58 tests pass **Band 3 in total** (#270–#274, five PRs): 99 functions and 122 variables left `src.ino` in 14 units (fram, settings, rtc, gps, imu, env, battery, weather, geocache, oled, display, ui_state, touch, web), plus `fc_version.h`; `src.ino` went from 7,986 lines before 3a to 4,387; Flash from 1,856,026 to 1,856,690 (+664, every byte traced: seven static initialisers, function relaxation and literal pools, string-literal splitting, alignment); RAM from 103,232 to 103,248 (+16, 3a's `.bss` packing). Every extraction was proven behaviour-neutral by symbol set and, from 3c on, by every moved function's reference set. Nothing was flashed: hardware verification of the whole sequence is Epic #212's integration test. +## 15. Band 4 — the screens; the god object is a residue + +**One branch, three proven commits, one PR left open.** #265's acceptance says *full hardware verification, every screen and every peripheral, before merge*, and nothing in E4 has been flashed. So band 4 is one branch on #265, `fc/212-e4-screens`, with a proven commit per unit and one PR that waits for the owner's hardware verification. It is not merged under the grant. + +**Composition, from the graph.** Clustering the 78 remaining functions by the static state they share gave one 34-function component of 163 statics — the LVGL widgets — with the shared statics few and explicable: each screen's screen object and header (navigation's `mainScreens` table and `updateSDIndicators` need them), the touch statistics, the settings scroll areas. Three commits: + +| commit | unit(s) | functions | what it owns | +|---|---|---|---| +| 1 | `lvgl_port` | the five LVGL callbacks, `initFCTheme` | the display, input-device and group objects, the draw-buffer pointers, the touch statistics (published), the seven named styles (private: only the theme touches them) | +| 2 | `oled_screens` | `updateOLED`, five `drawOLED*` | nothing of its own; renders published state | +| 3 | `screen_compass`, `screen_env`, `screen_telemetry`, `screen_geocache`, `screen_settings`, `navigation` | 58 | every widget of every screen (152 statics), each screen's object and header published; `mainScreens`, the list picker | + +Commit 3 is six units in one step because the screens and the navigation reference each other through their headers — a screen registers `screenGestureCb` and builds its header with `fcHeaderCreate`, navigation loads the screens — so any one of them alone would have reached back into `src.ino`. `initLVGL` stays: it builds every screen and wires the port, which is `setup()`'s kind of work, the composition root. + +**What is left** — `src/src.ino`, 750 lines, measured: 8 functions (`setup`, `loop`, `initLVGL`, `initSD`, `scanI2C`, `handleButtons` and its two helpers), 6 file-scope variables (three button-debounce values, three timers), **cut set 0, largest cluster 1**; the graph reports 7 of 8 functions touching no shared state of the file and the eighth (`handleButtons`) touching only its own three. #265 estimated "roughly 567 lines"; 750 is the number. 28 headers and 25 translation units hold the rest. + +**Found by the build, seven things, all recorded in the commits:** twelve static callbacks referenced before their definitions (a builder registers a callback defined after it — `src.ino` declared them at file scope; the harness now forward-declares every static function at the top of its unit and drops the file-scope prototypes); `screenGestureCb` published (every screen registers it); six statics the graph could not see (two write-only headers, two const-folded counts, an inlined colour helper, the settings menu buttons array) moved by hand and marked; `web.h` restoring the `fs::FS` alias `WebServer.h` relies on — TFT_eSPI defines `FS_NO_GLOBALS`, and `src.ino`'s include order used to hide it; the `LV_USE_LOG` guard around `lvglLogCb`, which the harness does not carry, restored by hand; `lvglTickCb` losing `static` because `initLVGL` registers it (`--publish-functions`); the button pins into `fc_config.h`. + +**Two harness faults caught before they wrote anything.** The existing-prototype matcher accepted any prefix and matched `case SCREEN_COMPASS: updateCompassData(); …; break;` inside `updateDisplay` — it would have deleted the switch case as a redundant prototype. The dry run showed it; it now requires type words at column 0 and nothing after the semicolon. And the baseline for band 3e (§14) had raced the extraction; band 4 captured every baseline before touching the tree. + +**Measured**, per commit — `scripts/verify_extraction.py`, PASS each time: + +``` +unit 1 lvgl_port 592 -> 592 symbols, 27 relocated, 13 linkage changes declared (12 published + variables, the tick callback), 5 deltas net +2 all layout, 5 moved checked + Flash 1,856,690 -> 1,856,670 (-20) src.ino 4,387 -> 4,209 +unit 2 oled_screens 592 -> 592, 6 relocated, 1 delta (-1), 6 moved checked + .flash.text +60 (six calls now cross-TU) Flash -> 1,856,722 (+52) src.ino -> 3,993 +unit 3 screens + nav 592 -> 593 (a constprop clone of an LVGL inline, tolerated by pattern), + 213 relocated, 12 linkage changes declared, 6 deltas net +104 all attributed, + 61 moved functions checked by reference set (27 references now cross-TU) + .flash.text +184 (inputs +182), .flash.rodata -96 + Flash -> 1,856,810 (+88) src.ino -> 804, then 750 after comments +RAM 103,248 throughout arduino-cli SUCCESS 58 tests pass +``` + +**E4 in total.** `src/src.ino` went from 8,742 lines (band 1's start) to 750 — 70 functions and about 170 statics left in band 4, 99 functions and 122 variables in band 3, 33 functions in bands 1 and 2 — into 25 translation units and 28 headers. Flash from 1,855,962 to 1,856,810 (+848 bytes, 0.05%, every byte traced to static initialisers, cross-TU calls, literal pools, string splitting and alignment); RAM from 103,232 to 103,248 (+16). Every move was proven behaviour-neutral by symbol set and, from band 3c on, by every moved function's reference set. Nothing was flashed: hardware verification is #265's gate and Epic #212's integration test. + --- ## Appendix — the full tables diff --git a/src/lvgl_port.cpp b/src/lvgl_port.cpp index c3c23d9..50e8b91 100644 --- a/src/lvgl_port.cpp +++ b/src/lvgl_port.cpp @@ -48,11 +48,13 @@ void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { lv_display_flush_ready(disp); } +#if LV_USE_LOG != 0 // as in src.ino: compiled only when LVGL logging is on (initLVGL registers it under the same guard) void lvglLogCb(lv_log_level_t level, const char* buf) { LV_UNUSED(level); Serial.println(buf); Serial.flush(); } +#endif void lvglTouchReadCb(lv_indev_t* indev, lv_indev_data_t* data) { (void)indev; diff --git a/src/src.ino b/src/src.ino index 0911223..f10c81b 100644 --- a/src/src.ino +++ b/src/src.ino @@ -86,13 +86,7 @@ // TFT Display pins (ST7796U 3.5" IPS, SPI) — TFT_CS=18, TFT_DC=17, TFT_RST=16 are // defined in platformio.ini build_flags under USER_SETUP_LOADED=1, which tells // TFT_eSPI to skip its own header selection entirely. Not User_Setup.h (#184). -// FRAM_CS, SD_CS, SPI_* and TFT_BL live in fc_config.h (E4). - - -// FRAM Memory Map (256KB = 262,144 bytes, MB85RS2MTA) - - -// Screen dimensions (landscape mode after rotation) +// FRAM_CS, SD_CS, SPI_*, TFT_BL, CTP_INT and the button pins live in fc_config.h (E4). // Debounce time in ms #define DEBOUNCE_MS 200 @@ -106,22 +100,9 @@ // Watchdog configuration (auto-reset on hang) #define WDT_TIMEOUT_SEC 30 // Reset if loop hangs for 30 seconds -// ============== Global Objects ============== - - -// TFT_eSprite removed — LVGL handles all TFT rendering (#114) - -// Zone-based partial push system removed — LVGL handles dirty tracking (#114) - -// Capacitive touch controller (FT6336U on I2C at 0x38) -// touchDetected removed (#260): it was set by touchISR() and never read anywhere. -// The one write-only variable in the file, per the relocation analysis in -// docs/god-object-inventory.md. LVGL polls the controller; the ISR flag was vestigial. - -// Legacy swipe/tap detection removed — LVGL gesture + click callbacks (#113) - - // ============== Global State ============== +// Every peripheral object and every screen lives in its own unit now (E4, #212); +// what is left here belongs to setup, loop and the buttons. // Button debounce @@ -143,20 +124,6 @@ bool buttonCLongPressHandled = false; #define LONG_PRESS_MS 800 // 800ms for long press -// ============== LVGL Tick Callback ============== - -// ============== LVGL Flush Callback ============== - -// ============== LVGL Log Callback ============== - -#if LV_USE_LOG != 0 -#endif - -// ============== LVGL Touch Read Callback (#106) ============== - - -// ============== LVGL Encoder Read Callback (#106) ============== - // ============== Setup ============== void setup() { @@ -636,9 +603,6 @@ void initSD() { } -// ============== LVGL Screen Navigation (#113) ============== - - // ============== Button Handling ============== void handleButtons() { @@ -784,21 +748,3 @@ void handleButtonCLongPress() { } } -// ============== Display Functions ============== - -// Zone helper implementations removed — LVGL handles dirty tracking (#114) - -// Legacy TFT_eSprite draw functions removed — all rendering via LVGL (#114) -// Removed: drawHeader, drawNavBar, drawLabel, drawValue, drawScreenTelemetry, -// drawScreenEnv, drawScreenCompass, drawCompassRose (legacy sprite version), -// drawNavTriangle, drawSearchZoneCircle, drawScreenGeocache, -// drawCacheNavScreen, drawCacheListScreen, drawCacheDetailsScreen (~1,300 lines) - -// ============== OLED Display Functions ============== - -// drawOLEDScreenOps() removed — content now in Settings > About (#102) - -// ============== OLED Geocache Screen (#70) ============== - -// drawOLEDScreenDiags() removed — diagnostics now in Settings (#90) -