mod: Phase 1 - bring forward mod-exclusive files with ActivityManager migration

Brings ~55 mod-exclusive files to the upstream-based mod/master-resync branch:

Activities (migrated to new ActivityManager pattern):
- Clock/Time: SetTimeActivity, SetTimezoneOffsetActivity, NtpSyncActivity
- Dictionary: DictionaryDefinitionActivity, DictionarySuggestionsActivity,
  DictionaryWordSelectActivity, LookedUpWordsActivity
- Bookmark: EpubReaderBookmarkSelectionActivity
- Book management: BookManageMenuActivity, EndOfBookMenuActivity
- OPDS: OpdsServerListActivity, OpdsSettingsActivity
- Utility: DirectoryPickerActivity, NumericStepperActivity

Utilities (unchanged):
- BookManager, BookSettings, BookmarkStore, BootNtpSync
- Dictionary, LookupHistory, TimeSync, OpdsServerStore

Libraries: PlaceholderCover, TableData, ChapterXPathIndexer
Scripts: inject_mod_version, generate_book_icon, preview_placeholder_cover
Docs: KOReader sync XPath mapping

Migration changes:
- ActivityWithSubactivity -> Activity base class
- Callback constructors -> finish()/setResult() pattern
- enterNewActivity() -> startActivityForResult()
- Activity::RenderLock&& -> RenderLock&&

These files won't compile yet - they reference mod settings and I18n
strings that will be added in subsequent phases.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-07 15:10:00 -05:00
parent 170cc25774
commit dfbc931c14
147 changed files with 112771 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
#include "NtpSyncActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include "ActivityResult.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BootNtpSync.h"
#include "util/TimeSync.h"
static constexpr unsigned long AUTO_DISMISS_MS = 5000;
void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
LOG_ERR("NTP", "WiFi connection failed, exiting");
finish();
return;
}
LOG_DBG("NTP", "WiFi connected, starting NTP sync");
{
RenderLock lock(*this);
state = SYNCING;
}
requestUpdateAndWait();
const bool synced = TimeSync::waitForNtpSync(8000);
{
RenderLock lock(*this);
state = synced ? SUCCESS : FAILED;
if (synced) {
successTimestamp = millis();
}
}
requestUpdate();
if (synced) {
LOG_DBG("NTP", "Time synced successfully");
} else {
LOG_ERR("NTP", "NTP sync timed out");
}
}
void NtpSyncActivity::onEnter() {
Activity::onEnter();
BootNtpSync::cancel();
LOG_DBG("NTP", "Turning on WiFi...");
WiFi.mode(WIFI_STA);
LOG_DBG("NTP", "Launching WifiSelectionActivity...");
startActivityForResult(
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) {
const bool success = !result.isCancelled && std::holds_alternative<WifiResult>(result.data);
onWifiSelectionComplete(success);
});
}
void NtpSyncActivity::onExit() {
Activity::onExit();
TimeSync::stopNtpSync();
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
delay(100);
}
void NtpSyncActivity::render(RenderLock&&) {
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_SYNC_CLOCK));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto centerY = (pageHeight - lineHeight) / 2;
if (state == SYNCING) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNCING_TIME));
} else if (state == SUCCESS) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_TIME_SYNCED), true, EpdFontFamily::BOLD);
time_t now = time(nullptr);
struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) {
char timeBuf[32];
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
} else {
int hour12 = t->tm_hour % 12;
if (hour12 == 0) hour12 = 12;
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
}
renderer.drawCenteredText(UI_10_FONT_ID, centerY + lineHeight + metrics.verticalSpacing, timeBuf);
}
const unsigned long elapsed = millis() - successTimestamp;
const int remaining = static_cast<int>((AUTO_DISMISS_MS - elapsed + 999) / 1000);
char backLabel[32];
snprintf(backLabel, sizeof(backLabel), "%s (%d)", tr(STR_BACK), remaining > 0 ? remaining : 1);
const auto labels = mappedInput.mapLabels(backLabel, "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state == FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}
void NtpSyncActivity::loop() {
if (state == SUCCESS) {
const unsigned long elapsed = millis() - successTimestamp;
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || elapsed >= AUTO_DISMISS_MS) {
finish();
return;
}
const int currentSecond = static_cast<int>(elapsed / 1000);
if (currentSecond != lastCountdownSecond) {
lastCountdownSecond = currentSecond;
requestUpdate();
}
return;
}
if (state == FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
}
return;
}
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include "activities/Activity.h"
class NtpSyncActivity : public Activity {
enum State { WIFI_SELECTION, SYNCING, SUCCESS, FAILED };
State state = WIFI_SELECTION;
unsigned long successTimestamp = 0;
int lastCountdownSecond = -1;
void onWifiSelectionComplete(bool success);
public:
explicit NtpSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("NtpSync", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state == SYNCING; }
};

View File

@@ -0,0 +1,118 @@
#include "OpdsServerListActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "ActivityResult.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "OpdsSettingsActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
int OpdsServerListActivity::getItemCount() const {
int count = static_cast<int>(OPDS_STORE.getCount());
if (!pickerMode) {
count++;
}
return count;
}
void OpdsServerListActivity::onEnter() {
Activity::onEnter();
OPDS_STORE.loadFromFile();
selectedIndex = 0;
requestUpdate();
}
void OpdsServerListActivity::onExit() { Activity::onExit(); }
void OpdsServerListActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
const int itemCount = getItemCount();
if (itemCount > 0) {
buttonNavigator.onNext([this, itemCount] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
requestUpdate();
});
buttonNavigator.onPrevious([this, itemCount] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount);
requestUpdate();
});
}
}
void OpdsServerListActivity::handleSelection() {
const auto serverCount = static_cast<int>(OPDS_STORE.getCount());
if (pickerMode) {
if (selectedIndex < serverCount) {
setResult(PageResult{.page = static_cast<uint32_t>(selectedIndex)});
finish();
}
return;
}
const int idx = selectedIndex;
startActivityForResult(
std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, idx < serverCount ? idx : -1),
[this](const ActivityResult&) {
selectedIndex = 0;
requestUpdate();
});
}
void OpdsServerListActivity::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_OPDS_SERVERS));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
const int itemCount = getItemCount();
if (itemCount == 0) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS));
} else {
const auto& servers = OPDS_STORE.getServers();
const auto serverCount = static_cast<int>(servers.size());
// Primary label: server name (falling back to URL if unnamed).
// Secondary label: server URL (shown as subtitle when name is set).
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
[&servers, serverCount](int index) {
if (index < serverCount) {
const auto& server = servers[index];
return server.name.empty() ? server.url : server.name;
}
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
},
[&servers, serverCount](int index) {
if (index < serverCount && !servers[index].name.empty()) {
return servers[index].url;
}
return std::string("");
});
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
/**
* Activity showing the list of configured OPDS servers.
* Allows adding new servers and editing/deleting existing ones.
* Used from Settings and also as a server picker from the home screen.
*
* When pickerMode is true, selecting a server returns PageResult{.page = serverIndex} and finishes.
* When pickerMode is false (settings mode), selecting opens OpdsSettingsActivity for edit/add.
*/
class OpdsServerListActivity final : public Activity {
public:
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false)
: Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
int selectedIndex = 0;
bool pickerMode;
int getItemCount() const;
void handleSelection();
};

View File

@@ -0,0 +1,225 @@
#include "OpdsSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <cstring>
#include <string>
#include "ActivityResult.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "activities/util/NumericStepperActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Position, Name, URL, Username, Password, Download Path, After Download.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 7;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
}
void OpdsSettingsActivity::onEnter() {
Activity::onEnter();
selectedIndex = 0;
isNewServer = (serverIndex < 0);
if (!isNewServer) {
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex));
if (server) {
editServer = *server;
} else {
// Server was deleted between navigation and entering this screen — treat as new
isNewServer = true;
serverIndex = -1;
}
}
requestUpdate();
}
void OpdsSettingsActivity::onExit() { Activity::onExit(); }
void OpdsSettingsActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
return;
}
const int menuItems = getMenuItemCount();
buttonNavigator.onNext([this, menuItems] {
selectedIndex = (selectedIndex + 1) % menuItems;
requestUpdate();
});
buttonNavigator.onPrevious([this, menuItems] {
selectedIndex = (selectedIndex + menuItems - 1) % menuItems;
requestUpdate();
});
}
void OpdsSettingsActivity::saveServer() {
if (isNewServer) {
OPDS_STORE.addServer(editServer);
isNewServer = false;
} else {
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
}
// Re-locate our server after add/update may have re-sorted the vector
const auto& servers = OPDS_STORE.getServers();
for (size_t i = 0; i < servers.size(); i++) {
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
serverIndex = static_cast<int>(i);
break;
}
}
}
void OpdsSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Position (sort order)
startActivityForResult(
std::make_unique<NumericStepperActivity>(renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<PageResult>(result.data)) {
editServer.sortOrder = static_cast<int>(std::get<PageResult>(result.data).page);
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 1) {
// Server Name
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
editServer.name = std::get<KeyboardResult>(result.data).text;
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 2) {
// Server URL
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127,
false),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
editServer.url = std::get<KeyboardResult>(result.data).text;
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 3) {
// Username
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63,
false),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
editServer.username = std::get<KeyboardResult>(result.data).text;
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 4) {
// Password
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63,
false),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
editServer.password = std::get<KeyboardResult>(result.data).text;
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 5) {
// Download Path
startActivityForResult(
std::make_unique<DirectoryPickerActivity>(renderer, mappedInput, editServer.downloadPath),
[this](const ActivityResult& result) {
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
editServer.downloadPath = std::get<KeyboardResult>(result.data).text;
saveServer();
}
requestUpdate();
});
} else if (selectedIndex == 6) {
// After Download — toggle between 0 (back to listing) and 1 (open book)
editServer.afterDownloadAction = editServer.afterDownloadAction == 0 ? 1 : 0;
saveServer();
requestUpdate();
} else if (selectedIndex == 7 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
finish();
}
}
void OpdsSettingsActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header);
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
tr(STR_CALIBRE_URL_HINT));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
const int menuItems = getMenuItemCount();
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH,
StrId::STR_AFTER_DOWNLOAD};
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
[this, &fieldNames](int index) {
if (index < BASE_ITEMS) {
return std::string(I18N.get(fieldNames[index]));
}
return std::string(tr(STR_DELETE_SERVER));
},
nullptr, nullptr,
[this](int index) {
if (index == 0) {
return std::to_string(editServer.sortOrder);
} else if (index == 1) {
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
} else if (index == 2) {
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
} else if (index == 3) {
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
} else if (index == 4) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
} else if (index == 5) {
return editServer.downloadPath;
} else if (index == 6) {
return std::string(editServer.afterDownloadAction == 0 ? tr(STR_BACK_TO_LISTING) : tr(STR_OPEN_BOOK));
}
return std::string("");
},
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include "OpdsServerStore.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
/**
* Edit screen for a single OPDS server.
* Shows Name, URL, Username, Password fields and a Delete option.
* Used for both adding new servers and editing existing ones.
*/
class OpdsSettingsActivity final : public Activity {
public:
/**
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
*/
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1)
: Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
size_t selectedIndex = 0;
int serverIndex;
OpdsServer editServer;
bool isNewServer = false;
int getMenuItemCount() const;
void handleSelection();
void saveServer();
};

View File

@@ -0,0 +1,157 @@
#include "SetTimeActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <sys/time.h>
#include <cstdio>
#include <ctime>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void SetTimeActivity::onEnter() {
Activity::onEnter();
// Initialize from current system time if it's been set (year > 2000)
time_t now = time(nullptr);
struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) {
hour = t->tm_hour;
minute = t->tm_min;
} else {
hour = 12;
minute = 0;
}
selectedField = 0;
requestUpdate();
}
void SetTimeActivity::onExit() { Activity::onExit(); }
void SetTimeActivity::loop() {
// Back button: discard and exit
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
// Confirm button: apply time and exit
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
applyTime();
finish();
return;
}
// Left/Right: switch between hour and minute fields
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedField = 0;
requestUpdate();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedField = 1;
requestUpdate();
return;
}
// Up/Down: increment/decrement the selected field
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectedField == 0) {
hour = (hour + 1) % 24;
} else {
minute = (minute + 1) % 60;
}
requestUpdate();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedField == 0) {
hour = (hour + 23) % 24;
} else {
minute = (minute + 59) % 60;
}
requestUpdate();
return;
}
}
void SetTimeActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
// Title
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_TIME), true, EpdFontFamily::BOLD);
// Format hour and minute strings
char hourStr[4];
char minuteStr[4];
snprintf(hourStr, sizeof(hourStr), "%02d", hour);
snprintf(minuteStr, sizeof(minuteStr), "%02d", minute);
const int colonWidth = renderer.getTextWidth(UI_12_FONT_ID, " : ");
const int digitWidth = renderer.getTextWidth(UI_12_FONT_ID, "00");
const int totalWidth = digitWidth * 2 + colonWidth;
const int startX = (pageWidth - totalWidth) / 2;
const int timeY = 80;
// Draw selection highlight behind the selected field
constexpr int highlightPad = 6;
if (selectedField == 0) {
renderer.fillRoundedRect(startX - highlightPad, timeY - 4, digitWidth + highlightPad * 2, lineHeight12 + 8, 6,
Color::LightGray);
} else {
renderer.fillRoundedRect(startX + digitWidth + colonWidth - highlightPad, timeY - 4, digitWidth + highlightPad * 2,
lineHeight12 + 8, 6, Color::LightGray);
}
// Draw the time digits and colon
renderer.drawText(UI_12_FONT_ID, startX, timeY, hourStr, true);
renderer.drawText(UI_12_FONT_ID, startX + digitWidth, timeY, " : ", true);
renderer.drawText(UI_12_FONT_ID, startX + digitWidth + colonWidth, timeY, minuteStr, true);
// Draw up/down arrows above and below the selected field
const int arrowX = (selectedField == 0) ? startX + digitWidth / 2 : startX + digitWidth + colonWidth + digitWidth / 2;
const int arrowUpY = timeY - 20;
const int arrowDownY = timeY + lineHeight12 + 12;
// Up arrow (simple triangle using lines)
constexpr int arrowSize = 6;
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
}
// Down arrow
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
}
// Button hints
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
void SetTimeActivity::applyTime() {
time_t now = time(nullptr);
struct tm newTime = {};
struct tm* current = localtime(&now);
if (current != nullptr && current->tm_year > 100) {
newTime = *current;
} else {
// If time was never set, use a reasonable date (2025-01-01)
newTime.tm_year = 125; // years since 1900
newTime.tm_mon = 0;
newTime.tm_mday = 1;
}
newTime.tm_hour = hour;
newTime.tm_min = minute;
newTime.tm_sec = 0;
time_t newEpoch = mktime(&newTime);
struct timeval tv = {.tv_sec = newEpoch, .tv_usec = 0};
settimeofday(&tv, nullptr);
}

View File

@@ -0,0 +1,23 @@
#pragma once
#include "activities/Activity.h"
class SetTimeActivity final : public Activity {
public:
explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("SetTime", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
// 0 = editing hours, 1 = editing minutes
uint8_t selectedField = 0;
int hour = 12;
int minute = 0;
void applyTime();
};

View File

@@ -0,0 +1,101 @@
#include "SetTimezoneOffsetActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <cstdio>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void SetTimezoneOffsetActivity::onEnter() {
Activity::onEnter();
offsetHours = SETTINGS.timezoneOffsetHours;
requestUpdate();
}
void SetTimezoneOffsetActivity::onExit() { Activity::onExit(); }
void SetTimezoneOffsetActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
SETTINGS.timezoneOffsetHours = offsetHours;
SETTINGS.saveToFile();
// Apply timezone immediately
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
tzset();
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (offsetHours < 14) {
offsetHours++;
requestUpdate();
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (offsetHours > -12) {
offsetHours--;
requestUpdate();
}
return;
}
}
void SetTimezoneOffsetActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
// Title
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_UTC_OFFSET), true, EpdFontFamily::BOLD);
// Format the offset string
char offsetStr[16];
if (offsetHours >= 0) {
snprintf(offsetStr, sizeof(offsetStr), "UTC+%d", offsetHours);
} else {
snprintf(offsetStr, sizeof(offsetStr), "UTC%d", offsetHours);
}
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, offsetStr);
const int startX = (pageWidth - textWidth) / 2;
const int valueY = 80;
// Draw selection highlight
constexpr int highlightPad = 10;
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
Color::LightGray);
// Draw the offset text
renderer.drawText(UI_12_FONT_ID, startX, valueY, offsetStr, true);
// Draw up/down arrows
const int arrowX = pageWidth / 2;
const int arrowUpY = valueY - 20;
const int arrowDownY = valueY + lineHeight12 + 12;
constexpr int arrowSize = 6;
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
}
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
}
// Button hints
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,17 @@
#pragma once
#include "activities/Activity.h"
class SetTimezoneOffsetActivity final : public Activity {
public:
explicit SetTimezoneOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("SetTZOffset", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
int8_t offsetHours = 0;
};