feat: X3 clock display with DS3231 RTC and NTP sync (#1612)
This commit is contained in:
@@ -234,6 +234,21 @@ STR_BATTERY: "Battery"
|
||||
STR_XTC_STATUS_BAR: "XTC Status Bar"
|
||||
STR_BOTTOM: "Bottom"
|
||||
STR_TOP: "Top"
|
||||
STR_CLOCK: "Clock"
|
||||
STR_CLOCK_UTC_OFFSET: "Clock UTC Offset"
|
||||
STR_CLOCK_FORMAT: "Clock Format"
|
||||
STR_CLOCK_FORMAT_24H: "24-hour"
|
||||
STR_CLOCK_FORMAT_12H: "12-hour"
|
||||
STR_CURRENT_TIME: "Current time:"
|
||||
STR_NEXT_FIELD: "Next"
|
||||
STR_CLOCK_SYNC: "Sync Clock"
|
||||
STR_CLOCK_SYNC_NOW: "Sync clock now"
|
||||
STR_CLOCK_SYNCING: "Syncing from NTP..."
|
||||
STR_CLOCK_SYNC_OK: "Clock synced"
|
||||
STR_CLOCK_SYNC_FAIL: "Sync failed"
|
||||
STR_CLOCK_SYNC_NO_WIFI: "WiFi not connected"
|
||||
STR_CLOCK_SYNC_NO_WIFI_HINT: "Connect to WiFi first, then try again."
|
||||
STR_CLOCK_SYNCED: "Clock Synced"
|
||||
STR_UI_THEME: "UI Theme"
|
||||
STR_THEME_CLASSIC: "Classic"
|
||||
STR_THEME_LYRA: "Lyra"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#include "HalClock.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sntp.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
HalClock halClock; // Singleton instance
|
||||
|
||||
// DS3231 register layout (BCD encoded):
|
||||
// 0x00: Seconds (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x01: Minutes (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x02: Hours (bit 6 = 12/24 mode, bits 5-4 = tens, bits 3-0 = ones)
|
||||
|
||||
static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
|
||||
static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
|
||||
|
||||
void HalClock::begin() {
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// I2C is already initialised by HalPowerManager::begin() for X3.
|
||||
// Probe the DS3231 by reading the seconds register.
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
LOG_INF("CLK", "DS3231 RTC not found");
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)1);
|
||||
if (Wire.available() < 1) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.read(); // discard — just testing connectivity
|
||||
|
||||
_available = true;
|
||||
LOG_INF("CLK", "DS3231 RTC found");
|
||||
|
||||
// Prime the cache with an initial read
|
||||
uint8_t h, m;
|
||||
getTime(h, m);
|
||||
}
|
||||
|
||||
bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
if (!_available) return false;
|
||||
|
||||
const unsigned long now = millis();
|
||||
if (_lastPollMs != 0 && (now - _lastPollMs) < CLOCK_POLL_MS) {
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)3);
|
||||
if (Wire.available() < 3) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
Wire.read(); // seconds — not needed
|
||||
const uint8_t rawMin = Wire.read();
|
||||
const uint8_t rawHour = Wire.read();
|
||||
|
||||
_cachedMinute = bcdToDec(rawMin & 0x7F);
|
||||
// Handle 12/24h mode: bit 6 high = 12h mode
|
||||
if (rawHour & 0x40) {
|
||||
// 12h mode: bit 5 = PM, bits 4-0 = hours (1-12)
|
||||
uint8_t h12 = bcdToDec(rawHour & 0x1F);
|
||||
bool pm = rawHour & 0x20;
|
||||
if (h12 == 12) h12 = 0;
|
||||
_cachedHour = pm ? (h12 + 12) : h12;
|
||||
} else {
|
||||
// 24h mode: bits 5-0 = hours (0-23)
|
||||
_cachedHour = bcdToDec(rawHour & 0x3F);
|
||||
}
|
||||
_lastPollMs = now;
|
||||
_hasCachedTime = true;
|
||||
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased, bool use12Hour) const {
|
||||
if (bufSize < (use12Hour ? 9u : 6u)) return false;
|
||||
uint8_t h, m;
|
||||
if (!getTime(h, m)) return false;
|
||||
|
||||
// Apply UTC offset: convert biased value to signed quarter-hours.
|
||||
// Clamp against corrupted persisted values so display time can't drift outside [-12:00, +14:00].
|
||||
if (utcOffsetQuarterHoursBiased > 104) utcOffsetQuarterHoursBiased = 104;
|
||||
int offsetQuarterHours = static_cast<int>(utcOffsetQuarterHoursBiased) - 48;
|
||||
int totalMinutes = static_cast<int>(h) * 60 + static_cast<int>(m) + offsetQuarterHours * 15;
|
||||
|
||||
// Wrap around 24 hours
|
||||
totalMinutes = ((totalMinutes % 1440) + 1440) % 1440;
|
||||
|
||||
const int hour24 = totalMinutes / 60;
|
||||
const int min = totalMinutes % 60;
|
||||
if (use12Hour) {
|
||||
const bool pm = hour24 >= 12;
|
||||
int hour12 = hour24 % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(buf, bufSize, "%d:%02d %s", hour12, min, pm ? "PM" : "AM");
|
||||
} else {
|
||||
snprintf(buf, bufSize, "%02d:%02d", hour24, min);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
|
||||
assert(hour < 24);
|
||||
assert(minute < 60);
|
||||
assert(second < 60);
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG); // Start at register 0x00
|
||||
Wire.write(decToBcd(second)); // 0x00: Seconds
|
||||
Wire.write(decToBcd(minute)); // 0x01: Minutes
|
||||
Wire.write(decToBcd(hour)); // 0x02: Hours (24h mode, bit 6 = 0)
|
||||
if (Wire.endTransmission() != 0) {
|
||||
LOG_ERR("CLK", "Failed to write time to DS3231");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Invalidate cache so next read fetches fresh data
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = hour;
|
||||
_cachedMinute = minute;
|
||||
_hasCachedTime = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::syncFromNTP() {
|
||||
if (!_available) return false;
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
LOG_ERR("CLK", "WiFi not connected, cannot sync NTP");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_INF("CLK", "Starting NTP sync...");
|
||||
configTzTime("UTC0", "pool.ntp.org", "time.nist.gov");
|
||||
|
||||
// Wait for SNTP sync to complete (up to 5 seconds)
|
||||
constexpr int maxAttempts = 50;
|
||||
for (int i = 0; i < maxAttempts; i++) {
|
||||
if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm timeinfo;
|
||||
gmtime_r(&now, &timeinfo);
|
||||
|
||||
if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) {
|
||||
LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
delay(100);
|
||||
}
|
||||
|
||||
LOG_ERR("CLK", "NTP sync timed out");
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
|
||||
class HalClock;
|
||||
extern HalClock halClock; // Singleton
|
||||
|
||||
class HalClock {
|
||||
bool _available = false;
|
||||
mutable uint8_t _cachedHour = 0;
|
||||
mutable uint8_t _cachedMinute = 0;
|
||||
mutable bool _hasCachedTime = false;
|
||||
mutable unsigned long _lastPollMs = 0;
|
||||
|
||||
static constexpr unsigned long CLOCK_POLL_MS = 10000; // 10 seconds
|
||||
|
||||
public:
|
||||
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
|
||||
void begin();
|
||||
|
||||
// True if the DS3231 RTC is present on this device
|
||||
bool isAvailable() const { return _available; }
|
||||
|
||||
// Get current hour (0-23) and minute (0-59).
|
||||
// Returns false if RTC is not available.
|
||||
bool getTime(uint8_t& hour, uint8_t& minute) const;
|
||||
|
||||
// Format time into a caller-provided buffer.
|
||||
// 24h mode produces "HH:MM" (needs >=6 bytes); 12h mode produces "H:MM AM"/"HH:MM PM" (needs >=9 bytes).
|
||||
// utcOffsetQuarterHoursBiased: biased quarter-hour offset (48 = UTC+0, 0 = UTC-12, 104 = UTC+14).
|
||||
// use12Hour: when true, format as 12-hour clock with AM/PM suffix.
|
||||
// Returns false if RTC is not available.
|
||||
bool formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased = 48, bool use12Hour = false) const;
|
||||
|
||||
// Sync the DS3231 RTC from an NTP server. Requires WiFi to be connected.
|
||||
// Blocks for up to ~5s while waiting for SNTP response.
|
||||
// Returns true if the RTC was successfully updated.
|
||||
//
|
||||
// Debouncing (skip if already synced once) is enforced by the caller, not here,
|
||||
// so the HAL stays free of any app-layer settings dependency.
|
||||
bool syncFromNTP();
|
||||
|
||||
private:
|
||||
bool writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second);
|
||||
};
|
||||
@@ -170,6 +170,17 @@ class CrossPointSettings {
|
||||
uint8_t statusBarTitle = CHAPTER_TITLE;
|
||||
uint8_t statusBarBattery = 1;
|
||||
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
||||
// Clock display in status bar (X3 only, requires DS3231 RTC)
|
||||
uint8_t statusBarClock = 0;
|
||||
// Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t.
|
||||
// Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00.
|
||||
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
|
||||
uint8_t clockUtcOffsetQ = 48;
|
||||
// Clock display format: 0 = 24-hour, 1 = 12-hour
|
||||
uint8_t clockFormat = 0;
|
||||
// Set once an NTP sync succeeds. Used to skip re-syncing on every WiFi connect.
|
||||
// Resetting to 0 (e.g. via the web UI) forces a re-sync on next WiFi connect.
|
||||
uint8_t clockHasBeenSynced = 0;
|
||||
// Text rendering settings
|
||||
uint8_t extraParagraphSpacing = 1;
|
||||
uint8_t textAntiAliasing = 1;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalClock.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
#include <SdCardFontRegistry.h>
|
||||
@@ -229,6 +230,19 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode,
|
||||
{StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
|
||||
// Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48.
|
||||
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
|
||||
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
|
||||
{StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}, "clockFormat",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
// Persistence flag for NTP debounce. Resetting from the web UI forces a re-sync
|
||||
// on next WiFi connect, which is useful when crossing time zones.
|
||||
SettingInfo::Toggle(StrId::STR_CLOCK_SYNCED, &CrossPointSettings::clockHasBeenSynced, "clockHasBeenSynced",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
};
|
||||
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
|
||||
if (halTiltSensor.isAvailable()) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#include "WifiSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <map>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
@@ -248,6 +250,16 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
||||
connectedIP = ipStr;
|
||||
autoConnecting = false;
|
||||
|
||||
// Sync RTC from NTP on the first successful WiFi connection only. The DS3231
|
||||
// drifts ~2 ppm so one sync is enough; users can force a re-sync from
|
||||
// Settings > Customise Status Bar > Sync clock now.
|
||||
if (halClock.isAvailable() && !SETTINGS.clockHasBeenSynced) {
|
||||
if (halClock.syncFromNTP()) {
|
||||
SETTINGS.clockHasBeenSynced = 1;
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
// Save this as the last connected network - SD card operations need lock as
|
||||
// we use SPI for both
|
||||
{
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#include "ClockOffsetActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t MAX_POS_HOURS = 14;
|
||||
constexpr uint8_t MAX_NEG_HOURS = 12;
|
||||
constexpr uint8_t MINUTE_STEPS = 4; // 0, 15, 30, 45
|
||||
constexpr uint8_t MINUTES_PER_QUARTER = 15;
|
||||
constexpr uint8_t BIAS_QUARTER_HOURS = 48; // 0 stored = UTC-12, 48 stored = UTC+0
|
||||
|
||||
// Convert a (sign, hours, quarter) triple into the biased storage value.
|
||||
// Returns a value in [0, 104].
|
||||
uint8_t encodeOffset(uint8_t sign, uint8_t hours, uint8_t quarter) {
|
||||
int signedQuarter = static_cast<int>(hours) * 4 + static_cast<int>(quarter);
|
||||
if (sign == 1) signedQuarter = -signedQuarter;
|
||||
int biased = signedQuarter + BIAS_QUARTER_HOURS;
|
||||
if (biased < 0) biased = 0;
|
||||
if (biased > 104) biased = 104;
|
||||
return static_cast<uint8_t>(biased);
|
||||
}
|
||||
|
||||
// Decompose the biased storage value into (sign, hours, quarter).
|
||||
void decodeOffset(uint8_t biased, uint8_t& sign, uint8_t& hours, uint8_t& quarter) {
|
||||
if (biased > 104) biased = BIAS_QUARTER_HOURS;
|
||||
int signedQuarter = static_cast<int>(biased) - BIAS_QUARTER_HOURS;
|
||||
if (signedQuarter < 0) {
|
||||
sign = 1;
|
||||
signedQuarter = -signedQuarter;
|
||||
} else {
|
||||
sign = 0;
|
||||
}
|
||||
hours = static_cast<uint8_t>(signedQuarter / 4);
|
||||
quarter = static_cast<uint8_t>(signedQuarter % 4);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ClockOffsetActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
loadFromSettings();
|
||||
activeField = FIELD_HOURS;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::onExit() {
|
||||
saveToSettings();
|
||||
Activity::onExit();
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::loadFromSettings() {
|
||||
decodeOffset(SETTINGS.clockUtcOffsetQ, sign, hours, minutesQuarter);
|
||||
clampForSign();
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::saveToSettings() const {
|
||||
const uint8_t encoded = encodeOffset(sign, hours, minutesQuarter);
|
||||
if (encoded == SETTINGS.clockUtcOffsetQ) return;
|
||||
SETTINGS.clockUtcOffsetQ = encoded;
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::clampForSign() {
|
||||
const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS;
|
||||
if (hours > maxHours) hours = maxHours;
|
||||
// At the absolute boundary (-12:00 or +14:00) only :00 is valid.
|
||||
if (hours == maxHours && minutesQuarter != 0) {
|
||||
minutesQuarter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::adjustActiveField(int delta) {
|
||||
switch (activeField) {
|
||||
case FIELD_SIGN: {
|
||||
sign = static_cast<uint8_t>((sign + 1) % 2);
|
||||
clampForSign();
|
||||
break;
|
||||
}
|
||||
case FIELD_HOURS: {
|
||||
const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS;
|
||||
const int next = (static_cast<int>(hours) + delta + (maxHours + 1)) % (maxHours + 1);
|
||||
hours = static_cast<uint8_t>(next);
|
||||
clampForSign();
|
||||
break;
|
||||
}
|
||||
case FIELD_MINUTES: {
|
||||
// At the boundary hour, lock minutes to :00.
|
||||
const uint8_t maxHours = (sign == 1) ? MAX_NEG_HOURS : MAX_POS_HOURS;
|
||||
if (hours == maxHours) {
|
||||
minutesQuarter = 0;
|
||||
break;
|
||||
}
|
||||
const int next = (static_cast<int>(minutesQuarter) + delta + MINUTE_STEPS) % MINUTE_STEPS;
|
||||
minutesQuarter = static_cast<uint8_t>(next);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
activeField = static_cast<Field>((activeField + 1) % FIELD_COUNT);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
adjustActiveField(+1);
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
adjustActiveField(-1);
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onNextContinuous([this] {
|
||||
adjustActiveField(+1);
|
||||
requestUpdate();
|
||||
});
|
||||
buttonNavigator.onPreviousContinuous([this] {
|
||||
adjustActiveField(-1);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_UTC_OFFSET));
|
||||
|
||||
// Build the offset string. Use a generous font and centre it.
|
||||
char offsetBuf[16];
|
||||
snprintf(offsetBuf, sizeof(offsetBuf), "UTC %c %d:%02d", sign == 1 ? '-' : '+', hours,
|
||||
minutesQuarter * MINUTES_PER_QUARTER);
|
||||
|
||||
const int centreY = pageHeight / 2 - 40;
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, centreY, offsetBuf, true, EpdFontFamily::BOLD);
|
||||
|
||||
// Underline / caret under the active field. Compute positions by measuring substrings of the
|
||||
// formatted string so the caret follows the font glyph widths exactly.
|
||||
// Field substrings:
|
||||
// "UTC " -> prefix
|
||||
// "{+/-}" -> sign
|
||||
// " "
|
||||
// "{hours}" -> hours
|
||||
// ":"
|
||||
// "{mm}" -> minutes
|
||||
auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s); };
|
||||
const int totalWidth = widthOf(offsetBuf);
|
||||
const int leftEdge = (pageWidth - totalWidth) / 2;
|
||||
|
||||
// Locate each field by reformatting prefixes.
|
||||
char prefixSign[16];
|
||||
snprintf(prefixSign, sizeof(prefixSign), "UTC ");
|
||||
const int signX = leftEdge + widthOf(prefixSign);
|
||||
|
||||
char prefixHours[16];
|
||||
snprintf(prefixHours, sizeof(prefixHours), "UTC %c ", sign == 1 ? '-' : '+');
|
||||
const int hoursX = leftEdge + widthOf(prefixHours);
|
||||
|
||||
char prefixMinutes[16];
|
||||
snprintf(prefixMinutes, sizeof(prefixMinutes), "UTC %c %d:", sign == 1 ? '-' : '+', hours);
|
||||
const int minutesX = leftEdge + widthOf(prefixMinutes);
|
||||
|
||||
// Width of each field substring for the caret span.
|
||||
const int signW = widthOf(sign == 1 ? "-" : "+");
|
||||
char hoursStr[8];
|
||||
snprintf(hoursStr, sizeof(hoursStr), "%d", hours);
|
||||
const int hoursW = widthOf(hoursStr);
|
||||
char minutesStr[8];
|
||||
snprintf(minutesStr, sizeof(minutesStr), "%02d", minutesQuarter * MINUTES_PER_QUARTER);
|
||||
const int minutesW = widthOf(minutesStr);
|
||||
|
||||
int caretX = 0;
|
||||
int caretW = 0;
|
||||
switch (activeField) {
|
||||
case FIELD_SIGN:
|
||||
caretX = signX;
|
||||
caretW = signW;
|
||||
break;
|
||||
case FIELD_HOURS:
|
||||
caretX = hoursX;
|
||||
caretW = hoursW;
|
||||
break;
|
||||
case FIELD_MINUTES:
|
||||
caretX = minutesX;
|
||||
caretW = minutesW;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Caret drawn as a short bar below the active field.
|
||||
const int caretY = centreY + 10;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
renderer.drawLine(caretX, caretY + dy, caretX + caretW, caretY + dy);
|
||||
}
|
||||
|
||||
// Live preview of the resulting wall-clock time, so users can verify against a watch.
|
||||
if (halClock.isAvailable()) {
|
||||
char timeBuf[9];
|
||||
const uint8_t encoded = encodeOffset(sign, hours, minutesQuarter);
|
||||
if (halClock.formatTime(timeBuf, sizeof(timeBuf), encoded, SETTINGS.clockFormat == 1)) {
|
||||
char preview[24];
|
||||
snprintf(preview, sizeof(preview), "%s %s", tr(STR_CURRENT_TIME), timeBuf);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centreY + 60, preview);
|
||||
}
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_NEXT_FIELD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// Dedicated UTC offset picker for the status bar clock.
|
||||
// Three editable fields (sign, hours, minutes); Confirm cycles fields, Up/Down adjust the active one.
|
||||
// Supports the full IANA UTC offset range in 15 minute steps, including oddball zones like Nepal (+5:45).
|
||||
class ClockOffsetActivity final : public Activity {
|
||||
public:
|
||||
explicit ClockOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("ClockOffset", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
enum Field { FIELD_SIGN = 0, FIELD_HOURS = 1, FIELD_MINUTES = 2, FIELD_COUNT };
|
||||
Field activeField = FIELD_HOURS;
|
||||
|
||||
// Working copy of the offset, edited in-place. Saved back to SETTINGS on exit.
|
||||
// 0 = positive offset, 1 = negative offset.
|
||||
uint8_t sign = 0;
|
||||
// Hours: 0..14 when positive, 0..12 when negative.
|
||||
uint8_t hours = 0;
|
||||
// Quarter-hour index 0..3 (0, 15, 30, 45).
|
||||
uint8_t minutesQuarter = 0;
|
||||
|
||||
void loadFromSettings();
|
||||
void saveToSettings() const;
|
||||
void adjustActiveField(int delta);
|
||||
void clampForSign();
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "ClockSyncActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void ClockSyncActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
state = SYNCING;
|
||||
syncedTime[0] = '\0';
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void ClockSyncActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void ClockSyncActivity::runSync() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
LOG_INF("CLK", "Manual sync requested but WiFi is not connected");
|
||||
state = NO_WIFI;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const bool ok = halClock.syncFromNTP();
|
||||
if (!ok) {
|
||||
state = FAILED;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as synced so the auto-sync hook stops firing on future WiFi connects.
|
||||
SETTINGS.clockHasBeenSynced = 1;
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Read the freshly synced time back for the user-facing confirmation.
|
||||
char buf[9];
|
||||
if (halClock.formatTime(buf, sizeof(buf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
|
||||
snprintf(syncedTime, sizeof(syncedTime), "%s", buf);
|
||||
}
|
||||
state = SUCCESS;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void ClockSyncActivity::loop() {
|
||||
if (state == SYNCING) {
|
||||
// First-tick: render the "Syncing..." screen, then perform the (blocking) sync.
|
||||
// requestUpdateAndWait below forces the render before we block on WiFi.
|
||||
requestUpdateAndWait();
|
||||
runSync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
void ClockSyncActivity::render(RenderLock&&) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CLOCK_SYNC));
|
||||
|
||||
const int midY = pageHeight / 2;
|
||||
|
||||
switch (state) {
|
||||
case SYNCING:
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, midY, tr(STR_CLOCK_SYNCING));
|
||||
break;
|
||||
case SUCCESS: {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_OK), true, EpdFontFamily::BOLD);
|
||||
if (syncedTime[0] != '\0') {
|
||||
char line[32];
|
||||
snprintf(line, sizeof(line), "%s %s", tr(STR_CURRENT_TIME), syncedTime);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, line);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NO_WIFI:
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_NO_WIFI), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, tr(STR_CLOCK_SYNC_NO_WIFI_HINT));
|
||||
break;
|
||||
case FAILED:
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_FAIL), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, tr(STR_CHECK_SERIAL_OUTPUT));
|
||||
break;
|
||||
}
|
||||
|
||||
if (state != SYNCING) {
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OK_BUTTON), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
// Manual NTP resync action. Runs a forced sync (bypassing the once-per-device debounce),
|
||||
// reports success/failure, then waits for Back. Requires WiFi to already be connected.
|
||||
class ClockSyncActivity final : public Activity {
|
||||
public:
|
||||
explicit ClockSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("ClockSync", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool skipLoopDelay() override { return true; }
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
enum State { SYNCING, SUCCESS, NO_WIFI, FAILED };
|
||||
State state = SYNCING;
|
||||
char syncedTime[16] = {0};
|
||||
|
||||
void runSync();
|
||||
};
|
||||
@@ -1,24 +1,69 @@
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "ClockOffsetActivity.h"
|
||||
#include "ClockSyncActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 7;
|
||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT,
|
||||
StrId::STR_BOOK_PROGRESS_PERCENTAGE,
|
||||
StrId::STR_PROGRESS_BAR,
|
||||
StrId::STR_PROGRESS_BAR_THICKNESS,
|
||||
StrId::STR_TITLE,
|
||||
StrId::STR_BATTERY,
|
||||
StrId::STR_XTC_STATUS_BAR};
|
||||
// Menu items in their natural order. Clock entries are appended only when the
|
||||
// DS3231 RTC is present so X4 devices don't see them at all.
|
||||
enum MenuItem {
|
||||
ITEM_CHAPTER_PAGE_COUNT = 0,
|
||||
ITEM_BOOK_PROGRESS_PERCENTAGE,
|
||||
ITEM_PROGRESS_BAR,
|
||||
ITEM_PROGRESS_BAR_THICKNESS,
|
||||
ITEM_TITLE,
|
||||
ITEM_BATTERY,
|
||||
ITEM_XTC_STATUS_BAR,
|
||||
ITEM_CLOCK, // X3 only
|
||||
ITEM_CLOCK_FORMAT, // X3 only
|
||||
ITEM_CLOCK_UTC_OFFSET, // X3 only, launches ClockOffsetActivity
|
||||
ITEM_CLOCK_SYNC, // X3 only, launches ClockSyncActivity
|
||||
ITEM_COUNT
|
||||
};
|
||||
|
||||
constexpr int BASE_MENU_ITEMS = ITEM_CLOCK; // Items shown on every device
|
||||
constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is available
|
||||
|
||||
const StrId menuNames[FULL_MENU_ITEMS] = {
|
||||
StrId::STR_CHAPTER_PAGE_COUNT,
|
||||
StrId::STR_BOOK_PROGRESS_PERCENTAGE,
|
||||
StrId::STR_PROGRESS_BAR,
|
||||
StrId::STR_PROGRESS_BAR_THICKNESS,
|
||||
StrId::STR_TITLE,
|
||||
StrId::STR_BATTERY,
|
||||
StrId::STR_XTC_STATUS_BAR,
|
||||
StrId::STR_CLOCK,
|
||||
StrId::STR_CLOCK_FORMAT,
|
||||
StrId::STR_CLOCK_UTC_OFFSET,
|
||||
StrId::STR_CLOCK_SYNC_NOW,
|
||||
};
|
||||
|
||||
constexpr int CLOCK_FORMAT_ITEMS = 2;
|
||||
const StrId clockFormatNames[CLOCK_FORMAT_ITEMS] = {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H};
|
||||
|
||||
std::string formatUtcOffset(uint8_t biasedQ) {
|
||||
// biasedQ is in quarter-hour steps, biased by 48 (so 48 = UTC+0).
|
||||
if (biasedQ > 104) biasedQ = 48;
|
||||
int totalMinutes = (static_cast<int>(biasedQ) - 48) * 15;
|
||||
bool neg = totalMinutes < 0;
|
||||
int absMinutes = neg ? -totalMinutes : totalMinutes;
|
||||
int hours = absMinutes / 60;
|
||||
int mins = absMinutes % 60;
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins);
|
||||
return buf;
|
||||
}
|
||||
constexpr int PROGRESS_BAR_ITEMS = 3;
|
||||
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
||||
|
||||
@@ -32,7 +77,6 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI
|
||||
constexpr int XTC_STATUS_BAR_ITEMS = 3;
|
||||
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
|
||||
|
||||
const int widthMargin = 10;
|
||||
const int verticalPreviewPadding = 50;
|
||||
const int verticalPreviewTextPadding = 40;
|
||||
} // namespace
|
||||
@@ -41,6 +85,7 @@ void StatusBarSettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS;
|
||||
|
||||
// Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data
|
||||
if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) {
|
||||
@@ -59,6 +104,14 @@ void StatusBarSettingsActivity::onEnter() {
|
||||
SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE;
|
||||
}
|
||||
|
||||
if (SETTINGS.clockUtcOffsetQ > 104) {
|
||||
SETTINGS.clockUtcOffsetQ = 48; // Default to UTC+0
|
||||
}
|
||||
|
||||
if (SETTINGS.clockFormat >= CLOCK_FORMAT_ITEMS) {
|
||||
SETTINGS.clockFormat = 0;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -78,49 +131,65 @@ void StatusBarSettingsActivity::loop() {
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS);
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS);
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEMS);
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEMS);
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void StatusBarSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// Chapter Page Count
|
||||
SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2;
|
||||
} else if (selectedIndex == 1) {
|
||||
// Book Progress %
|
||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||
} else if (selectedIndex == 2) {
|
||||
// Progress Bar
|
||||
SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS;
|
||||
} else if (selectedIndex == 3) {
|
||||
// Progress Bar Thickness
|
||||
SETTINGS.statusBarProgressBarThickness =
|
||||
(SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS;
|
||||
} else if (selectedIndex == 4) {
|
||||
// Chapter Title
|
||||
SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS;
|
||||
} else if (selectedIndex == 5) {
|
||||
// Show Battery
|
||||
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
||||
} else if (selectedIndex == 6) {
|
||||
// XTC Status Bar
|
||||
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
|
||||
switch (selectedIndex) {
|
||||
case ITEM_CHAPTER_PAGE_COUNT:
|
||||
SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2;
|
||||
break;
|
||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||
break;
|
||||
case ITEM_PROGRESS_BAR:
|
||||
SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS;
|
||||
break;
|
||||
case ITEM_PROGRESS_BAR_THICKNESS:
|
||||
SETTINGS.statusBarProgressBarThickness =
|
||||
(SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS;
|
||||
break;
|
||||
case ITEM_TITLE:
|
||||
SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS;
|
||||
break;
|
||||
case ITEM_BATTERY:
|
||||
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
||||
break;
|
||||
case ITEM_XTC_STATUS_BAR:
|
||||
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
|
||||
break;
|
||||
case ITEM_CLOCK:
|
||||
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2;
|
||||
break;
|
||||
case ITEM_CLOCK_FORMAT:
|
||||
SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS;
|
||||
break;
|
||||
case ITEM_CLOCK_UTC_OFFSET:
|
||||
// Launch the dedicated offset picker. It saves on exit, no result handler needed.
|
||||
startActivityForResult(std::make_unique<ClockOffsetActivity>(renderer, mappedInput), nullptr);
|
||||
return;
|
||||
case ITEM_CLOCK_SYNC:
|
||||
startActivityForResult(std::make_unique<ClockSyncActivity>(renderer, mappedInput), nullptr);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
@@ -137,27 +206,36 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(MENU_ITEMS),
|
||||
static_cast<int>(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr,
|
||||
nullptr,
|
||||
[this](int index) {
|
||||
// Draw status for each setting
|
||||
if (index == 0) {
|
||||
return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
} else if (index == 1) {
|
||||
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
} else if (index == 2) {
|
||||
return I18N.get(progressBarNames[SETTINGS.statusBarProgressBar]);
|
||||
} else if (index == 3) {
|
||||
return I18N.get(progressBarThicknessNames[SETTINGS.statusBarProgressBarThickness]);
|
||||
} else if (index == 4) {
|
||||
return I18N.get(titleNames[SETTINGS.statusBarTitle]);
|
||||
} else if (index == 5) {
|
||||
return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
} else if (index == 6) {
|
||||
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
|
||||
} else {
|
||||
return tr(STR_HIDE);
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, visibleItemCount, static_cast<int>(selectedIndex),
|
||||
[](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr,
|
||||
[](int index) -> std::string {
|
||||
switch (index) {
|
||||
case ITEM_CHAPTER_PAGE_COUNT:
|
||||
return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
case ITEM_PROGRESS_BAR:
|
||||
return I18N.get(progressBarNames[SETTINGS.statusBarProgressBar]);
|
||||
case ITEM_PROGRESS_BAR_THICKNESS:
|
||||
return I18N.get(progressBarThicknessNames[SETTINGS.statusBarProgressBarThickness]);
|
||||
case ITEM_TITLE:
|
||||
return I18N.get(titleNames[SETTINGS.statusBarTitle]);
|
||||
case ITEM_BATTERY:
|
||||
return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
case ITEM_XTC_STATUS_BAR:
|
||||
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
|
||||
case ITEM_CLOCK:
|
||||
return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
case ITEM_CLOCK_FORMAT: {
|
||||
const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0;
|
||||
return std::string(I18N.get(clockFormatNames[fmt]));
|
||||
}
|
||||
case ITEM_CLOCK_UTC_OFFSET:
|
||||
return formatUtcOffset(SETTINGS.clockUtcOffsetQ);
|
||||
case ITEM_CLOCK_SYNC:
|
||||
return SETTINGS.clockHasBeenSynced ? tr(STR_CLOCK_SYNCED) : tr(STR_NOT_SET);
|
||||
default:
|
||||
return tr(STR_HIDE);
|
||||
}
|
||||
},
|
||||
true);
|
||||
|
||||
@@ -21,6 +21,8 @@ class StatusBarSettingsActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
int selectedIndex = 0;
|
||||
// Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4.
|
||||
int visibleItemCount = 0;
|
||||
|
||||
void handleSelection();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "BaseTheme.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <HalPowerManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
@@ -758,6 +759,19 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
showBatteryPercentage);
|
||||
}
|
||||
|
||||
// Draw Clock (X3 only — DS3231 RTC)
|
||||
int clockTextWidth = 0;
|
||||
if (SETTINGS.statusBarClock && halClock.isAvailable()) {
|
||||
char timeBuf[9];
|
||||
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
|
||||
clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
|
||||
// Position to the left of the progress text (with a small gap)
|
||||
const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight -
|
||||
progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth;
|
||||
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Title
|
||||
if (!title.empty()) {
|
||||
textY -= textYOffset;
|
||||
@@ -768,7 +782,8 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
|
||||
const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0;
|
||||
const int titleMarginLeft = batterySize + 30;
|
||||
const int titleMarginRight = progressTextWidth + 30;
|
||||
const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0;
|
||||
const int titleMarginRight = progressTextWidth + clockReserve + 30;
|
||||
|
||||
// Attempt to center title on the screen, but if title is too wide then later we will center it within the
|
||||
// available space.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <FontCacheManager.h>
|
||||
#include <FontDecompressor.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalClock.h>
|
||||
#include <HalDisplay.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <HalPowerManager.h>
|
||||
@@ -273,6 +274,7 @@ void setup() {
|
||||
gpio.begin();
|
||||
powerManager.begin();
|
||||
halTiltSensor.begin();
|
||||
halClock.begin();
|
||||
|
||||
#ifdef ENABLE_SERIAL_LOG
|
||||
if (gpio.isUsbConnected()) {
|
||||
|
||||
Reference in New Issue
Block a user