From 6ae0e7366326ca78ba0cc4ace338e5a7e6b3531b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:58:56 +0200 Subject: [PATCH] feat(l1): clock page and lock screen in the splash lettering The Home clock page draws HH:MM in the boot splash's slanted digits, with a seconds bar, a centred date and the clock fields as columns. The lock screen keeps the status bar, puts the big clock under it and shows unread messages in a pill; the unlock hint appears for a few seconds after a key press on the lit screen (not the waking one). Settings > Display > Lock clock adds a compact look: a small clock with the date beside it and the clock fields as rows (NodePrefs lock_compact, schema 0xC0DE0033). 12-hour time sets AM / PM beside the digits. Co-Authored-By: Claude Opus 5.5 --- examples/companion_radio/DataStore.cpp | 5 + examples/companion_radio/NodePrefs.h | 11 +- examples/companion_radio/ui-core/Lettering.h | 6 +- .../companion_radio/ui-core/SettingsSchema.h | 2 + examples/companion_radio/ui-new/UITask.cpp | 471 ++++++++++-------- examples/companion_radio/ui-new/UITask.h | 6 +- 6 files changed, 284 insertions(+), 217 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c7699fcb..ac2aeb6f 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -646,6 +646,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no // batt_curve_mv: any point out of range or not rising -> the built-in curve. rd(_prefs.batt_curve_mv, sizeof(_prefs.batt_curve_mv)); if (!battery::validCurve(_prefs.batt_curve_mv)) memset(_prefs.batt_curve_mv, 0, sizeof(_prefs.batt_curve_mv)); + rd(&_prefs.lock_compact, sizeof(_prefs.lock_compact)); + if (_prefs.lock_compact > 1) _prefs.lock_compact = 0; // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() and the clamps above already keep every @@ -660,6 +662,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no // file it holds its sentinel's bytes, which would read as a PIN nobody knows. // 0xC0DE0031 → 0xC0DE0032: the battery curve appended; an older file // leaves stray bytes in it, so the built-in curve. + // 0xC0DE0032 → 0xC0DE0033: the lock screen look appended; big by default. + if (sentinel < 0xC0DE0033) _prefs.lock_compact = 0; if (sentinel < 0xC0DE0032) memset(_prefs.batt_curve_mv, 0, sizeof(_prefs.batt_curve_mv)); if (sentinel < 0xC0DE0031) { memset(_prefs.lock_screen_password, 0, sizeof(_prefs.lock_screen_password)); @@ -866,6 +870,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.lock_screen_password, sizeof(_prefs.lock_screen_password)); file.write((uint8_t *)_prefs.lock_screen_password_salt, sizeof(_prefs.lock_screen_password_salt)); file.write((uint8_t *)_prefs.batt_curve_mv, sizeof(_prefs.batt_curve_mv)); + file.write((uint8_t *)&_prefs.lock_compact, sizeof(_prefs.lock_compact)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index aa1b17f3..c5a7cf52 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -563,6 +563,11 @@ struct NodePrefs { // persisted to file static const uint8_t BATT_CURVE_PTS = 11; uint16_t batt_curve_mv[BATT_CURVE_PTS]; + // ── Lock screen look (0xC0DE0033) ────────────────────────────────────── + // ui-new: 0 = big clock + date + unread pill, 1 = compact clock with the + // clock fields as rows below. The L2 has its own lock screen. + uint8_t lock_compact; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -650,7 +655,7 @@ struct NodePrefs { // persisted to file // repeat_* fields) instead of at the tail, which shifted every field after // them by 25 bytes when loading an older file. Never released, but a dev // build wrote it, so the number must not be reused for anything else. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0032; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0033; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage @@ -837,7 +842,9 @@ static inline bool inQuietHours(const NodePrefs& p, uint32_t utc) { // lock_screen_password / _salt (0xC0DE0031) added 48 bytes -- sizeof 2880. // batt_curve_mv[11] (0xC0DE0032) added 16 bytes, not 22 (6 went into tail // padding) -- sizeof 2896, confirmed via real L1 (nRF52) and L2 (ESP32) builds. -static_assert(sizeof(NodePrefs) == 2896, +// lock_compact (0xC0DE0033) added 8 bytes, not 1 (the struct rounds up to its +// alignment) -- sizeof 2904, confirmed via real L1 (nRF52) and L2 (ESP32) builds. +static_assert(sizeof(NodePrefs) == 2904, "NodePrefs layout changed — sync DataStore save/load + clamp, bump " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ui-core/Lettering.h b/examples/companion_radio/ui-core/Lettering.h index d295b787..84812080 100644 --- a/examples/companion_radio/ui-core/Lettering.h +++ b/examples/companion_radio/ui-core/Lettering.h @@ -1,8 +1,9 @@ #pragma once // The MeshCore wordmark and its lettering, shared by both boot splashes -// (ui-new/UITask.cpp on the OLED/e-ink boards, ui-lvgl/Splash.h on the L2): +// (ui-new/UITask.cpp on the OLED/e-ink boards, ui-lvgl/Splash.h on the L2) +// and ui-new's big clock (Home clock page, lock screen): // the 128x13 wordmark bitmap, its own letters, the extra characters drawn in -// the same strokes (digits, '.', '-', v, l, d, space), and the Solo version / +// the same strokes (digits, '.', ':', '-', v, l, d, space), and the Solo version / // build date strings both splashes show. Platform-free: callers plot pixels. #include @@ -55,6 +56,7 @@ static const PixGlyph GLYPHS[] = { { '8', { F10, F10, F10, B10, B10, F10, F10, F10, B10, B10, F10, F10, F10 } }, { '9', { F10, F10, F10, B10, B10, F10, F10, F10, R10, R10, F10, F10, F10 } }, { '.', { "....", "....", "....", "....", "....", "....", "....", "....", "....", "....", "###.", "###.", "###." } }, + { ':', { "....", "....", "....", "###.", "###.", "###.", "....", "....", "....", "###.", "###.", "###.", "...." } }, { '-', { ".......", ".......", ".......", ".......", ".......", "#######", "#######", "#######", ".......", ".......", ".......", ".......", "......." } }, { 'v', { "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", "###.....###", diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 17905e4b..8c3edfe0 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -131,6 +131,7 @@ static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) { static const char* L[] = { "Icon", "Percent", "Voltage" }; snprintf(b, n, "%s", L[v < 3 ? v : 0]); } +static void optLockLook(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "Compact" : "Big"); } static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); } @@ -197,6 +198,7 @@ static const Setting ALL[] = { MAP("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED, nullptr, nullptr), SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr), + IDX("Lock clock", "Big, or compact with the clock fields", SEC_DISPLAY, lock_compact, 2, optLockLook, nullptr), IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr), MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr), diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 39aaf213..08a33ded 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -195,83 +195,101 @@ static void fmtAlt(char* buf, int n, float meters, bool imperial) { // Voltage -> battery %, from the Core (top bar and dashboard Batt% alike). static int battMvToPercent(int mv, int low_mv) { return battery::percent(mv, low_mv); } -// Render the time starting at top_y; returns the y just below the time block -// so the caller can flow the date / dashboard rows beneath it. -// -// On a tall portrait panel (e-ink in portrait — height > width) HH and MM are -// stacked on two lines in the huge built-in font (size 4, ~56 px tall) so the -// digits fill the narrow width. On wide panels (OLED, landscape e-ink) the -// classic single-line "HH:MM" at size 2 is kept. -static int drawClockTime(DisplayDriver& d, int top_y, const struct tm* ti, - bool h12, bool show_sec, bool alignCenter = false) { - const bool tall = d.height() > d.width(); // true only on portrait e-ink +// The time on a tall portrait panel (e-ink in portrait -- height > width): HH +// and MM stacked on two lines in the huge built-in font (size 4, ~56 px tall) +// so the digits fill the narrow width. Returns the y just below it. +static int drawClockTall(DisplayDriver& d, int top_y, const struct tm* ti, bool h12) { + int hh = ti->tm_hour; + const char* ap = nullptr; + if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; } + const int cx = d.width() / 2; + char hbuf[4], mbuf[4]; + snprintf(hbuf, sizeof(hbuf), "%02d", hh); + snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min); - if (tall) { - int hh = ti->tm_hour; - const char* ap = nullptr; - if (h12) { ap = (hh < 12) ? "AM" : "PM"; hh %= 12; if (hh == 0) hh = 12; } - const int cx = d.width() / 2; - char hbuf[4], mbuf[4]; - snprintf(hbuf, sizeof(hbuf), "%02d", hh); - snprintf(mbuf, sizeof(mbuf), "%02d", ti->tm_min); - - int y = top_y; - d.setTextSize(4); - const int lhb = d.getLineHeight(); - // The built-in GFX font advances 6 px per char but the glyph is only 5 px - // wide, so getTextWidth() over-reports by one trailing blank column and - // drawTextCentered() would bias the digits ~half a column to the left. - // Centre on the visible width (minus that trailing column) instead. - const int trail = d.getCharWidth() / 6; // one built-in column at this size - auto drawBig = [&](const char* s, int yy) { - if (alignCenter) { - int w = (int)d.getTextWidth(s) - trail; - d.setCursor(cx - w / 2, yy); - d.print(s); - } else { - d.setCursor(0, yy); - d.print(s); - } - }; - drawBig(hbuf, y); y += lhb + 2; - drawBig(mbuf, y); y += lhb + 2; - if (ap) { - d.setTextSize(2); - if (alignCenter) { - d.drawTextCentered(cx, y, ap); - } else { - d.setCursor(0, y); - d.print(ap); - } - y += d.getLineHeight() + 1; - } - d.setTextSize(1); - return y; - } - - // Wide layout: single inline line at size 2. - char buf[16]; - d.setTextSize(2); - const int lh2 = d.getLineHeight(); - if (h12) { - int hh = ti->tm_hour % 12; if (hh == 0) hh = 12; - const char* ap = (ti->tm_hour < 12) ? "AM" : "PM"; - if (show_sec) snprintf(buf, sizeof(buf), "%d:%02d:%02d%s", hh, ti->tm_min, ti->tm_sec, ap); - else snprintf(buf, sizeof(buf), "%d:%02d %s", hh, ti->tm_min, ap); - } else { - if (show_sec) snprintf(buf, sizeof(buf), "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); - else snprintf(buf, sizeof(buf), "%02d:%02d", ti->tm_hour, ti->tm_min); - } - if (alignCenter) { - d.drawTextCentered(d.width() / 2, top_y, buf); - } else { - d.setCursor(0, top_y); - d.print(buf); + int y = top_y; + d.setTextSize(4); + const int lhb = d.getLineHeight(); + // The built-in GFX font advances 6 px per char but the glyph is only 5 px + // wide, so getTextWidth() over-reports by one trailing blank column and + // drawTextCentered() would bias the digits ~half a column to the left. + // Centre on the visible width (minus that trailing column) instead. + const int trail = d.getCharWidth() / 6; // one built-in column at this size + auto drawBig = [&](const char* s, int yy) { + d.setCursor(cx - ((int)d.getTextWidth(s) - trail) / 2, yy); + d.print(s); + }; + drawBig(hbuf, y); y += lhb + 2; + drawBig(mbuf, y); y += lhb + 2; + if (ap) { + d.setTextSize(2); + d.drawTextCentered(cx, y, ap); + y += d.getLineHeight() + 1; } d.setTextSize(1); - return top_y + lh2 + 2; + return y; } +// The big clock on wide panels (Home clock page, lock screen): HH:MM in the +// splash's slanted lettering (ui-core/Lettering.h) at `sc`, centred on cx. A +// 12-hour clock drops the leading zero, closes the gaps up and sets AM / PM +// beside the digits on their baseline -- "12:59 PM" still fits 128 px. +struct BigClock { int sc, h; }; +static BigClock bigClockSize(const DisplayDriver& d) { + int sc = d.width() / 64; + if (sc > d.height() / 32) sc = d.height() / 32; + if (sc < 1) sc = 1; + return { sc, lettering::LOGO_H * sc }; +} +static void drawLettering(DisplayDriver& d, int x, int y, const char* t, int sc, int gap) { + for (const char* p = t; *p; p++) { + const int cw = lettering::charW(*p); + if (cw < 0) continue; + for (int r = 0; r < lettering::LOGO_H; r++) + for (int col = 0; col < cw; col++) { + if (!lettering::inked(*p, col, r)) continue; + int e = col; + while (e + 1 < cw && lettering::inked(*p, e + 1, r)) e++; + d.fillRect(x + col * sc, y + r * sc, (e - col + 1) * sc, sc); + col = e; + } + x += cw * sc + gap; + } +} +// The big clock's text, gaps and AM / PM, measured: its full width (w), the +// digits' (dw). draw() puts its left edge at x. +struct BigClockText { + char t[8]; + const char* ap = nullptr; + int sc, gap, dw, w; + BigClockText(DisplayDriver& d, const struct tm* ti, bool h12, int scale) : sc(scale), gap(scale + 1) { + if (h12) { + int hh = ti->tm_hour % 12; if (hh == 0) hh = 12; + snprintf(t, sizeof(t), "%d:%02d", hh, ti->tm_min); + ap = ti->tm_hour < 12 ? "AM" : "PM"; + gap = sc / 2; + } else { + snprintf(t, sizeof(t), "%02d:%02d", ti->tm_hour, ti->tm_min); + } + dw = lettering::textW(t, 0) * sc + gap * ((int)strlen(t) - 1); + d.setTextSize(1); + w = dw + (ap ? (int)d.getTextWidth(ap) + 2 * sc : 0); + } + void draw(DisplayDriver& d, int x, int y) const { + drawLettering(d, x, y, t, sc, gap); + if (!ap) return; + d.setCursor(x + dw + 2 * sc, y + lettering::LOGO_H * sc - d.getLineHeight() + 1); + d.print(ap); + } +}; +static void drawBigClock(DisplayDriver& d, int cx, int y, const struct tm* ti, bool h12, int sc) { + BigClockText bt(d, ti, h12, sc); + bt.draw(d, cx - bt.w / 2, y); +} + +static const char* const WDAY[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; +static const char* const MON[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + // ── HomeScreen ──────────────────────────────────────────────────────────────── // Forward declaration to be able to call formatDashVal from HomeScreen::render() static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, uint16_t low_batt_mv, @@ -1029,6 +1047,169 @@ public: } } + // The clock fields set in Settings (dashboard_fields), in order; their count. + int clockFields(uint8_t* out) { + int n = 0; + if (_node_prefs) + for (int k = 0; k < 3; k++) + if (_node_prefs->dashboard_fields[k] != telemetry::NONE && _node_prefs->dashboard_fields[k] < telemetry::COUNT) + out[n++] = _node_prefs->dashboard_fields[k]; + return n; + } + // One field's value without its noun (the label beside it names it); "-" when there is none. + void clockFieldValue(uint8_t f, char* val, int n) { + formatDashVal(f, val, n, _task->getBattMilliVolts(), _node_prefs->low_batt_mv, + _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(), + _task->getAnyUnreadOverflow(), _node_prefs->units_imperial, &sensors_lpp, false); + if (!val[0]) snprintf(val, n, "-"); + } + + // ── Clock page: the big clock, a seconds bar under it, the date, and the + // clock fields (Settings) as columns along the bottom, name over value. + // Without fields the clock and date sit in the middle. + void drawClockPage(DisplayDriver& d, uint32_t unix_ts) { + struct tm ti; + localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); + const bool h12 = _node_prefs && _node_prefs->clock_12h; + const bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds); + const int W = d.width(), H = d.height(); + d.setColor(DisplayDriver::LIGHT); + d.setTextSize(1); + const int lh = d.getLineHeight(); + + uint8_t fields[3]; + const int nf = clockFields(fields); + + int date_y; + if (d.height() > d.width()) { // portrait e-ink: stacked digits + date_y = drawClockTall(d, 0, &ti, h12); + } else { + const BigClock bc = bigClockSize(d); + const int bar = 3 * bc.sc / 2; // the seconds bar and its gaps + const int block = bc.h + bar + 1 + lh; + const int y = nf ? bc.sc : (H - block) / 2; + drawBigClock(d, W / 2, y, &ti, h12, bc.sc); + const int bar_y = y + bc.h + bar / 2 + 1; + if (show_sec) { // fills over the minute: dotted track, solid part + const int x0 = W / 8, bw = W - 2 * x0, fill = bw * ti.tm_sec / 60; + d.fillRect(x0, bar_y, fill, 1); + for (int x = x0 + fill + (fill & 1); x < x0 + bw; x += 2) d.fillRect(x, bar_y, 1, 1); + } + // Alarm armed: the bell at the bar's left end (the status bar, which + // shows it everywhere else, is hidden on this page). + if (_node_prefs && _node_prefs->alarm_on) miniIconDrawTop(d, W / 32, bar_y - 2, ICON_ALARM); + date_y = y + bc.h + bar + 1; + } + char buf[24]; + snprintf(buf, sizeof(buf), "%s %d %s %d", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], 1900 + ti.tm_year); + d.drawTextCentered(W / 2, date_y, buf); + + if (nf == 0) return; + refresh_sensors(); + const int val_y = H - lh, lab_y = val_y - lh - 1, cw = W / nf; + for (int k = 0; k < nf; k++) { + char val[20]; + clockFieldValue(fields[k], val, sizeof(val)); + const int x0 = k * cw; + auto cell = [&](int yy, const char* t) { + if ((int)d.getTextWidth(t) <= cw - 2) d.drawTextCentered(x0 + cw / 2, yy, t); + else d.drawTextEllipsized(x0 + 1, yy, cw - 2, t); + }; + cell(lab_y, telemetry::LABEL[fields[k]]); + cell(val_y, val); + } + } + + // ── Lock screen: the status bar (drawn by render()), then one of two + // looks (Settings > Display > Lock clock): + // big -- the big clock, the date, unread messages in a pill; + // compact -- a small clock with the date beside it, the clock fields as + // rows below (plus unread messages when no field shows them). + // A key press on the lit screen (not the one that wakes it, so a glance + // shows the data) puts the unlock hint in a pill at the bottom for a few + // seconds (UITask::lockHintShown); e-ink keeps it up, it has no glance. + void drawLockPage(DisplayDriver& d) { + const int W = d.width(), H = d.height(); + d.setColor(DisplayDriver::LIGHT); + d.setTextSize(1); + const int lh = d.getLineHeight(), top = lh + 3; + const bool hint = Features::IS_EINK || _task->lockHintShown(); + const int pill_y = H - lh - 3; // the bottom pill, clear of what's above + const int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); + const uint32_t unix_ts = _rtc->getCurrentTime(); + const bool synced = unix_ts >= 1000000000UL; + struct tm ti; + if (synced) localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, ti); + const bool h12 = _node_prefs && _node_prefs->clock_12h; + const bool compact = _node_prefs && _node_prefs->lock_compact && W > H; + + char pill[24] = ""; + if (compact) { + const int m = W / 16, x0 = W / 32; + int sc = bigClockSize(d).sc / 2; + if (sc < 1) sc = 1; + const int ch = lettering::LOGO_H * sc; + if (!synced) { + d.setCursor(x0, top + (ch - lh + 1) / 2); + d.print("No time sync"); + } else { // the date beside the clock, without the weekday if it won't fit + BigClockText bt(d, &ti, h12, sc); + bt.draw(d, x0, top); + char date[16]; + snprintf(date, sizeof(date), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); + if (x0 + bt.w + lh / 2 + (int)d.getTextWidth(date) > W - x0) + snprintf(date, sizeof(date), "%d %s", ti.tm_mday, MON[ti.tm_mon]); + d.drawTextRightAlign(W - x0, top + (ch - lh + 1) / 2, date); + } + uint8_t rows[4]; + int nr = clockFields(rows); + bool has_msgs = false; + for (int k = 0; k < nr; k++) if (rows[k] == telemetry::MSGS) has_msgs = true; + if (!has_msgs && unread > 0) rows[nr++] = telemetry::MSGS; + if (nr) refresh_sensors(); + const int step = d.lineStep(); + for (int k = 0, y = top + ch + 6; k < nr && y + lh <= H; k++, y += step) { + if (hint && y + lh >= pill_y) break; // the hint pill takes the bottom + char val[20]; + clockFieldValue(rows[k], val, sizeof(val)); + info::valueRow(d, y, telemetry::LABEL[rows[k]], val, false, m - 2, m); + } + } else { + if (!synced) { + d.drawTextCentered(W / 2, H / 2 - lh, "No time sync"); + } else { + int date_y; + if (H > W) { + date_y = drawClockTall(d, top, &ti, h12); + } else { + const BigClock bc = bigClockSize(d); + drawBigClock(d, W / 2, top, &ti, h12, bc.sc); + date_y = top + bc.h + 3; + } + char buf[16]; + snprintf(buf, sizeof(buf), "%s %d %s", WDAY[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon]); + d.drawTextCentered(W / 2, date_y, buf); + } + if (!hint && unread > 0) snprintf(pill, sizeof(pill), "%d%s new", unread, _task->getAnyUnreadOverflow() ? "+" : ""); + } + + if (hint) { + const int n = _task->lockSeqCount(); +#if defined(CARDKB_I2C) + const char* first = _task->hasCardKB() ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter"; +#else + const char* first = "Hold Back + 3xEnter"; +#endif + snprintf(pill, sizeof(pill), "%s", n == 0 ? first : n == 1 ? "Enter x2 more..." : "Enter x1 more..."); + } + if (!pill[0]) return; + const int pw = d.getTextWidth(pill) + 6; + d.fillSoftRect((W - pw) / 2, pill_y, pw, lh + 3); + d.setColor(DisplayDriver::DARK); + d.drawTextCentered(W / 2, pill_y + 2, pill); + d.setColor(DisplayDriver::LIGHT); + } + // The Status page: four tiles split by dotted rules, each one subject's // headline (icon + main value) over a detail line. Enter opens the Status // screen with all of it. @@ -1130,18 +1311,9 @@ public: // Hidden on fullscreen pages (CLOCK). if (_page != CLOCK) { display.setColor(DisplayDriver::LIGHT); - int lock_reserve = -1; - if (_page == LOCK) { - // The lock screen's own clock shares row 0 with this title bar -- - // reserve its real footprint instead of the usual name_min, so the - // (already priority-ordered) status icons shed low-priority ones as - // needed and never draw over it. Built-in font is fixed-width, so a - // worst-case digit string measures this without the actual time. - bool tall = display.height() > display.width(); - display.setTextSize(tall ? 4 : 2); - lock_reserve = display.getTextWidth(tall ? "88" : "88:88"); - display.setTextSize(1); - } + // The lock page has nothing on the left of the bar (its clock sits + // below it), so the status icons may use the whole row. + const int lock_reserve = _page == LOCK ? 0 : -1; int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts(), lock_reserve); display.setColor(DisplayDriver::LIGHT); @@ -1227,135 +1399,10 @@ public: display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or"); display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app"); } else { - struct tm lt; - localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, lt); - struct tm* ti = < - - char buf[24]; - display.setColor(DisplayDriver::LIGHT); - bool show_sec = !Features::IS_EINK && (!_node_prefs || !_node_prefs->clock_hide_seconds); - bool h12 = _node_prefs && _node_prefs->clock_12h; - int date_y = drawClockTime(display, 0, ti, h12, show_sec, true); - - display.setTextSize(1); - static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; - static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; - snprintf(buf, sizeof(buf),"%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); - display.setCursor(0, date_y); - display.print(buf); - - // Alarm armed: a small bell in the top-left corner. The status bar (and - // its bell) is hidden on this page, so signal the armed alarm here. Just - // the glyph — no time text — so it stays clear of the centred clock - // digits (which can reach the corner when seconds are shown), matching - // the icon-only status-bar indicator. The exact time is in Clock Tools. - if (_node_prefs && _node_prefs->alarm_on) { - display.setColor(DisplayDriver::LIGHT); - miniIconDrawTop(display, 0, 0, ICON_ALARM); - } - - int sep_y = date_y + lh + 1; - int dash0 = sep_y + display.sepH() + 2; - display.fillRect(0, sep_y, display.width(), display.sepH()); - - // dashboard data fields - if (_node_prefs) { - refresh_sensors(); - const int FIELD_Y[3] = { dash0, dash0 + step, dash0 + step * 2 }; - for (int fi = 0; fi < 3; fi++) { - uint8_t field = _node_prefs->dashboard_fields[fi]; - if (field == telemetry::NONE) continue; - - const char* label = telemetry::LABEL[field < telemetry::COUNT ? field : telemetry::NONE]; - char val[20]; - formatDashVal(field, val, sizeof(val), _task->getBattMilliVolts(), _node_prefs->low_batt_mv, - _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(), - _task->getAnyUnreadOverflow(), _node_prefs->units_imperial, &sensors_lpp, false); - - if (val[0] && label[0]) { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(0, FIELD_Y[fi]); - display.print(label); - int vw = display.getTextWidth(val); - display.setCursor(display.width() - vw - 1, FIELD_Y[fi]); - display.print(val); - } - } - } + drawClockPage(display, unix_ts); } } else if (_page == HomePage::LOCK) { - // Lock screen: clock + two dashboard spots + unlock-hint popup - uint32_t unix_ts = _rtc->getCurrentTime(); - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); - if (unix_ts < 1000000000UL) { - display.drawTextCentered(display.width() / 2, display.height() / 2 - step, "No time sync"); - } else { - struct tm lt; - localTm(unix_ts, _node_prefs ? _node_prefs->tz_offset_hours : 0, lt); - struct tm* ti = < - char buf[12]; - bool h12 = _node_prefs && _node_prefs->clock_12h; - // Left-aligned on purpose: the rest of the top row is left for status icons. - int date_y = drawClockTime(display, 0, ti, h12, /*show_sec*/false); - display.setTextSize(1); - static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; - static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; - snprintf(buf, sizeof(buf),"%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); - display.setCursor(0, date_y); - display.print(buf); - - // Two sensor values side by side (dashboard_fields[0] and [1]) - if (_node_prefs) { - char v0[20] = "", v1[20] = ""; - CayenneLPP* lpp_ptr = nullptr; - uint8_t f0 = _node_prefs->dashboard_fields[0], f1 = _node_prefs->dashboard_fields[1]; - auto isLPP = [](uint8_t f) { - return f==telemetry::TEMP||f==telemetry::HUM||f==telemetry::PRES||f==telemetry::ALT||f==telemetry::LUX||f==telemetry::CO2; - }; - if (isLPP(f0) || isLPP(f1)) { - sensors_lpp.reset(); sensors.querySensors(0xFF, sensors_lpp); lpp_ptr = &sensors_lpp; - } - bool show_msgs = f0 == telemetry::MSGS || f1 == telemetry::MSGS; - int unread = show_msgs - ? _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount() : 0; - bool unread_overflow = show_msgs && _task->getAnyUnreadOverflow(); - uint16_t batt_mv = _task->getBattMilliVolts(); - formatDashVal(f0, v0, sizeof(v0), batt_mv, _node_prefs->low_batt_mv, unread, unread_overflow, _node_prefs->units_imperial, lpp_ptr, true); - formatDashVal(f1, v1, sizeof(v1), batt_mv, _node_prefs->low_batt_mv, unread, unread_overflow, _node_prefs->units_imperial, lpp_ptr, true); - if (v0[0] || v1[0]) { - int sv_y = date_y + step; - display.setColor(DisplayDriver::LIGHT); - if (v0[0] && v1[0]) { - display.setCursor(0, sv_y); - display.print(v0); - int vw = display.getTextWidth(v1); - display.setCursor(display.width() - vw, sv_y); - display.print(v1); - } else { - const char* sv = v0[0] ? v0 : v1; - display.drawTextCentered(display.width() / 2, sv_y, sv); - } - } - } - } - // Unlock-hint popup at the bottom (like alert style) - display.setTextSize(1); - const int lk_lh = display.getLineHeight(); -#if defined(CARDKB_I2C) - const char* hint = _task->lockSeqCount() == 0 ? (_task->hasCardKB() ? "Back+3xEnter/Fn+Esc" : "Hold Back + 3xEnter") : - _task->lockSeqCount() == 1 ? "Enter x2 more..." : "Enter x1 more..."; -#else - const char* hint = _task->lockSeqCount() == 0 ? "Hold Back + 3xEnter" : - _task->lockSeqCount() == 1 ? "Enter x2 more..." : "Enter x1 more..."; -#endif - const int p = 3; - const int hy = display.height() - lk_lh - p * 2; - const int hw = display.getTextWidth(hint); - const int hx = (display.width() - hw) / 2; - display.drawPanel(hx - p, hy - p, hw + p*2, lk_lh + p*2); - display.setCursor(hx, hy); - display.print(hint); + drawLockPage(display); } else if (_page == HomePage::STATUS) { drawStatusTiles(display, content_y); } else if (isQuickPage(_page) && !(_page == HomePage::SHUTDOWN && _shutdown_init)) { @@ -2998,7 +3045,7 @@ void UITask::loop() { } else { _kq_head = _kq_tail = 0; // locked or no screen: eat all queued keys // Locked: wake window is set only when display first turns on - if (_locked) _next_refresh = 0; + if (_locked) { _lock_hint_ms = millis(); _next_refresh = 0; } // the hint; not on the waking press, so a glance shows the data } } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index e4656dd6..967b08ca 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -65,6 +65,7 @@ class UITask : public UITaskBase, public UiCoreHost { unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) + unsigned long _lock_hint_ms; // millis() of the last key press on the lock screen (0: none) bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) // True while the lock screen shows the on-screen keyboard and waits for submission bool _unlock_kb = false; @@ -255,7 +256,7 @@ public: _batt_mv = 0; _locked = false; _lock_wake_until = 0; - _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; + _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; _lock_hint_ms = 0; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); @@ -420,6 +421,9 @@ public: // Lock-screen support: the HomeScreen LOCK page draws the unlock hint from // these (the platform combo is either "Back+3xEnter" or the CardKB nybble). int lockSeqCount() const { return _lock_seq_count; } + // The lock screen shows the unlock hint for a few seconds after a key press + // (and all through the Back + 3x Enter sequence). + bool lockHintShown() const { return _lock_seq_count > 0 || (_lock_hint_ms && millis() - _lock_hint_ms < 3000); } bool hasCardKB() const { #if defined(CARDKB_I2C) return _has_cardkb;