diff --git a/src/SettingsList.h b/src/SettingsList.h new file mode 100644 index 00000000..e373afa9 --- /dev/null +++ b/src/SettingsList.h @@ -0,0 +1,84 @@ +#pragma once +#include + +#include "CrossPointSettings.h" +#include "KOReaderCredentialStore.h" +#include "activities/settings/CategorySettingsActivity.h" + +// Returns the list of all settings, used by both the device UI and the web API. +// Categories match the device UI grouping. Settings with categories that don't +// match a device UI category (e.g. "KOReader Sync", "OPDS Browser") are +// web-only — they correspond to device sub-screens accessed via Action items. +inline std::vector getSettingsList() { + return { + // Display + SettingInfo::Enum("sleepScreen", "Sleep Screen", "Display", &CrossPointSettings::sleepScreen, + {"Dark", "Light", "Custom", "Cover", "None"}), + SettingInfo::Enum("sleepScreenCoverMode", "Sleep Screen Cover Mode", "Display", + &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), + SettingInfo::Enum("sleepScreenCoverFilter", "Sleep Screen Cover Filter", "Display", + &CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}), + SettingInfo::Enum("statusBar", "Status Bar", "Display", &CrossPointSettings::statusBar, + {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), + SettingInfo::Enum("hideBatteryPercentage", "Hide Battery %", "Display", + &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), + SettingInfo::Enum("refreshFrequency", "Refresh Frequency", "Display", &CrossPointSettings::refreshFrequency, + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + + // Reader + SettingInfo::Enum("fontFamily", "Font Family", "Reader", &CrossPointSettings::fontFamily, + {"Bookerly", "Noto Sans", "Open Dyslexic"}), + SettingInfo::Enum("fontSize", "Font Size", "Reader", &CrossPointSettings::fontSize, + {"Small", "Medium", "Large", "X Large"}), + SettingInfo::Enum("lineSpacing", "Line Spacing", "Reader", &CrossPointSettings::lineSpacing, + {"Tight", "Normal", "Wide"}), + SettingInfo::Value("screenMargin", "Screen Margin", "Reader", &CrossPointSettings::screenMargin, {5, 40, 5}), + SettingInfo::Enum("paragraphAlignment", "Paragraph Alignment", "Reader", &CrossPointSettings::paragraphAlignment, + {"Justify", "Left", "Center", "Right"}), + SettingInfo::Toggle("hyphenationEnabled", "Hyphenation", "Reader", &CrossPointSettings::hyphenationEnabled), + SettingInfo::Enum("orientation", "Reading Orientation", "Reader", &CrossPointSettings::orientation, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), + SettingInfo::Toggle("extraParagraphSpacing", "Extra Paragraph Spacing", "Reader", + &CrossPointSettings::extraParagraphSpacing), + SettingInfo::Toggle("textAntiAliasing", "Text Anti-Aliasing", "Reader", &CrossPointSettings::textAntiAliasing), + + // Controls + SettingInfo::Enum( + "frontButtonLayout", "Front Button Layout", "Controls", &CrossPointSettings::frontButtonLayout, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}), + SettingInfo::Enum("sideButtonLayout", "Side Button Layout (reader)", "Controls", + &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), + SettingInfo::Toggle("longPressChapterSkip", "Long-press Chapter Skip", "Controls", + &CrossPointSettings::longPressChapterSkip), + SettingInfo::Enum("shortPwrBtn", "Short Power Button Click", "Controls", &CrossPointSettings::shortPwrBtn, + {"Ignore", "Sleep", "Page Turn"}), + + // System + SettingInfo::Enum("sleepTimeout", "Time to Sleep", "System", &CrossPointSettings::sleepTimeout, + {"1 min", "5 min", "10 min", "15 min", "30 min"}), + + // KOReader Sync (device sub-screen accessed via System > KOReader Sync action) + SettingInfo::DynamicString( + "koUsername", "Username", "KOReader Sync", [] { return KOREADER_STORE.getUsername(); }, + [](const std::string& v) { KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword()); }, 64), + SettingInfo::DynamicString( + "koPassword", "Password", "KOReader Sync", [] { return KOREADER_STORE.getPassword(); }, + [](const std::string& v) { KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v); }, 64), + SettingInfo::DynamicString( + "koServerUrl", "Sync Server URL", "KOReader Sync", [] { return KOREADER_STORE.getServerUrl(); }, + [](const std::string& v) { KOREADER_STORE.setServerUrl(v); }, 128), + SettingInfo::DynamicEnum( + "koMatchMethod", "Document Matching", "KOReader Sync", + [] { return static_cast(KOREADER_STORE.getMatchMethod()); }, + [](uint8_t v) { KOREADER_STORE.setMatchMethod(static_cast(v)); }, + {"Filename", "Binary"}), + + // OPDS Browser (device sub-screen accessed via System > OPDS Browser action) + SettingInfo::String("opdsServerUrl", "Server URL", "OPDS Browser", SETTINGS.opdsServerUrl, + sizeof(SETTINGS.opdsServerUrl) - 1), + SettingInfo::String("opdsUsername", "Username", "OPDS Browser", SETTINGS.opdsUsername, + sizeof(SETTINGS.opdsUsername) - 1), + SettingInfo::String("opdsPassword", "Password", "OPDS Browser", SETTINGS.opdsPassword, + sizeof(SETTINGS.opdsPassword) - 1), + }; +} diff --git a/src/activities/settings/CategorySettingsActivity.h b/src/activities/settings/CategorySettingsActivity.h index a7d1f0ce..1ece3c54 100644 --- a/src/activities/settings/CategorySettingsActivity.h +++ b/src/activities/settings/CategorySettingsActivity.h @@ -11,12 +11,16 @@ class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; +enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; struct SettingInfo { - const char* name; + const char* key; // JSON key for web API (nullptr for ACTION types) + const char* name; // Display name of the setting + const char* category; // Category for grouping in web UI (nullptr = uncategorized) SettingType type; - uint8_t CrossPointSettings::* valuePtr; + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE) + char* stringPtr; // Pointer to char array (for STRING type) + size_t stringMaxLen; // Max length for STRING type std::vector enumValues; struct ValueRange { @@ -26,18 +30,61 @@ struct SettingInfo { }; ValueRange valueRange; - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; + // Dynamic accessors for settings not in CrossPointSettings + std::function valueGetter; + std::function valueSetter; + std::function stringGetter; + std::function stringSetter; + + static SettingInfo Toggle(const char* key, const char* name, const char* category, + uint8_t CrossPointSettings::* ptr) { + return {key, name, category, SettingType::TOGGLE, ptr, nullptr, 0, {}, {}}; } - static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { - return {name, SettingType::ENUM, ptr, std::move(values)}; + static SettingInfo Enum(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr, + std::vector values) { + return {key, name, category, SettingType::ENUM, ptr, nullptr, 0, std::move(values), {}}; } - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + static SettingInfo Action(const char* name) { + return {nullptr, name, nullptr, SettingType::ACTION, nullptr, nullptr, 0, {}, {}}; + } - static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { - return {name, SettingType::VALUE, ptr, {}, valueRange}; + static SettingInfo Value(const char* key, const char* name, const char* category, uint8_t CrossPointSettings::* ptr, + const ValueRange valueRange) { + return {key, name, category, SettingType::VALUE, ptr, nullptr, 0, {}, valueRange}; + } + + static SettingInfo String(const char* key, const char* name, const char* category, char* ptr, size_t maxLen) { + return {key, name, category, SettingType::STRING, nullptr, ptr, maxLen, {}, {}}; + } + + static SettingInfo DynamicEnum(const char* key, const char* name, const char* category, + std::function getter, std::function setter, + std::vector values) { + SettingInfo info{}; + info.key = key; + info.name = name; + info.category = category; + info.type = SettingType::ENUM; + info.enumValues = std::move(values); + info.valueGetter = std::move(getter); + info.valueSetter = std::move(setter); + return info; + } + + static SettingInfo DynamicString(const char* key, const char* name, const char* category, + std::function getter, std::function setter, + size_t maxLen) { + SettingInfo info{}; + info.key = key; + info.name = name; + info.category = category; + info.type = SettingType::STRING; + info.stringMaxLen = maxLen; + info.stringGetter = std::move(getter); + info.stringSetter = std::move(setter); + return info; } }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7316db05..2722b262 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -3,59 +3,15 @@ #include #include -#include "CategorySettingsActivity.h" +#include + #include "CrossPointSettings.h" #include "MappedInputManager.h" +#include "SettingsList.h" #include "fontIds.h" const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; -namespace { -constexpr int displaySettingsCount = 6; -const SettingInfo displaySettings[displaySettingsCount] = { - // Should match with SLEEP_SCREEN_MODE - SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), - SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), - SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, - {"None", "Contrast", "Inverted"}), - SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, - {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), - SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), - SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; - -constexpr int readerSettingsCount = 9; -const SettingInfo readerSettings[readerSettingsCount] = { - SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), - SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), - SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), - SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), - SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment, - {"Justify", "Left", "Center", "Right"}), - SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled), - SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, - {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), - SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), - SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)}; - -constexpr int controlsSettingsCount = 4; -const SettingInfo controlsSettings[controlsSettingsCount] = { - SettingInfo::Enum( - "Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}), - SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, - {"Prev, Next", "Next, Prev"}), - SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), - SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})}; - -constexpr int systemSettingsCount = 5; -const SettingInfo systemSettings[systemSettingsCount] = { - SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, - {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), - SettingInfo::Action("Check for updates")}; -} // namespace - void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -68,6 +24,23 @@ void SettingsActivity::onEnter() { // Reset selection to first category selectedCategoryIndex = 0; + // Build per-category settings from the single source of truth + for (auto& cat : categorySettings) cat.clear(); + for (const auto& setting : getSettingsList()) { + if (!setting.category) continue; + for (int i = 0; i < categoryCount; i++) { + if (strcmp(setting.category, categoryNames[i]) == 0) { + categorySettings[i].push_back(setting); + break; + } + } + } + // Append device-only Action items to System (index 3) + categorySettings[3].push_back(SettingInfo::Action("KOReader Sync")); + categorySettings[3].push_back(SettingInfo::Action("OPDS Browser")); + categorySettings[3].push_back(SettingInfo::Action("Clear Cache")); + categorySettings[3].push_back(SettingInfo::Action("Check for updates")); + // Trigger first update updateRequired = true; @@ -132,30 +105,9 @@ void SettingsActivity::enterCategory(int categoryIndex) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); - const SettingInfo* settingsList = nullptr; - int settingsCount = 0; - - switch (categoryIndex) { - case 0: // Display - settingsList = displaySettings; - settingsCount = displaySettingsCount; - break; - case 1: // Reader - settingsList = readerSettings; - settingsCount = readerSettingsCount; - break; - case 2: // Controls - settingsList = controlsSettings; - settingsCount = controlsSettingsCount; - break; - case 3: // System - settingsList = systemSettings; - settingsCount = systemSettingsCount; - break; - } - - enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList, - settingsCount, [this] { + const auto& settings = categorySettings[categoryIndex]; + enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settings.data(), + static_cast(settings.size()), [this] { exitActivity(); updateRequired = true; })); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 821dda42..8900e4dd 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -7,20 +7,19 @@ #include #include +#include "CategorySettingsActivity.h" #include "activities/ActivityWithSubactivity.h" -class CrossPointSettings; -struct SettingInfo; - class SettingsActivity final : public ActivityWithSubactivity { + static constexpr int categoryCount = 4; + static const char* categoryNames[categoryCount]; + TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; int selectedCategoryIndex = 0; // Currently selected category const std::function onGoHome; - - static constexpr int categoryCount = 4; - static const char* categoryNames[categoryCount]; + std::vector categorySettings[categoryCount]; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index a135c9f0..5ca469ff 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -8,9 +8,12 @@ #include #include +#include +#include "SettingsList.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" +#include "html/SettingsPageHtml.generated.h" #include "util/StringUtils.h" namespace { @@ -112,6 +115,11 @@ void CrossPointWebServer::begin() { // Delete file/folder endpoint server->on("/delete", HTTP_POST, [this] { handleDelete(); }); + // Settings endpoints + server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); }); + server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); + server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -936,3 +944,168 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* break; } } + +void CrossPointWebServer::handleSettingsPage() const { + server->send(200, "text/html", SettingsPageHtml); + Serial.printf("[%lu] [WEB] Served settings page\n", millis()); +} + +void CrossPointWebServer::handleGetSettings() const { + const auto settings = getSettingsList(); + + JsonDocument doc; + JsonArray settingsArray = doc["settings"].to(); + + for (const auto& setting : settings) { + // Skip ACTION types - they don't have web-editable values + if (setting.type == SettingType::ACTION) { + continue; + } + + JsonObject obj = settingsArray.add(); + obj["key"] = setting.key; + obj["name"] = setting.name; + if (setting.category) { + obj["category"] = setting.category; + } + + switch (setting.type) { + case SettingType::TOGGLE: + obj["type"] = "toggle"; + obj["value"] = (setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr)) ? 1 : 0; + break; + + case SettingType::ENUM: { + obj["type"] = "enum"; + obj["value"] = setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr); + JsonArray opts = obj["options"].to(); + for (const auto& opt : setting.enumValues) { + opts.add(opt); + } + break; + } + + case SettingType::VALUE: + obj["type"] = "value"; + obj["value"] = setting.valueGetter ? setting.valueGetter() : SETTINGS.*(setting.valuePtr); + obj["min"] = setting.valueRange.min; + obj["max"] = setting.valueRange.max; + obj["step"] = setting.valueRange.step; + break; + + case SettingType::STRING: + obj["type"] = "string"; + if (setting.stringGetter) { + obj["value"] = setting.stringGetter(); + } else { + obj["value"] = setting.stringPtr; + } + obj["maxLength"] = setting.stringMaxLen; + break; + + case SettingType::ACTION: + break; + } + } + + String json; + serializeJson(doc, json); + server->send(200, "application/json", json); + Serial.printf("[%lu] [WEB] Served settings JSON\n", millis()); +} + +void CrossPointWebServer::handlePostSettings() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing request body"); + return; + } + + const String body = server->arg("plain"); + Serial.printf("[%lu] [WEB] Received settings update: %s\n", millis(), body.c_str()); + + JsonDocument doc; + const DeserializationError error = deserializeJson(doc, body); + + if (error) { + Serial.printf("[%lu] [WEB] JSON parse error: %s\n", millis(), error.c_str()); + server->send(400, "text/plain", "Invalid JSON"); + return; + } + + const auto settings = getSettingsList(); + int updatedCount = 0; + + for (const auto& setting : settings) { + if (setting.type == SettingType::ACTION || setting.key == nullptr) { + continue; + } + + if (doc[setting.key].isNull()) { + continue; + } + + switch (setting.type) { + case SettingType::TOGGLE: { + const int value = doc[setting.key].as(); + if (setting.valueSetter) { + setting.valueSetter(value ? 1 : 0); + } else { + SETTINGS.*(setting.valuePtr) = value ? 1 : 0; + } + updatedCount++; + break; + } + + case SettingType::ENUM: { + const int value = doc[setting.key].as(); + if (value >= 0 && value < static_cast(setting.enumValues.size())) { + if (setting.valueSetter) { + setting.valueSetter(static_cast(value)); + } else { + SETTINGS.*(setting.valuePtr) = static_cast(value); + } + updatedCount++; + } + break; + } + + case SettingType::VALUE: { + const int value = doc[setting.key].as(); + if (value >= setting.valueRange.min && value <= setting.valueRange.max) { + if (setting.valueSetter) { + setting.valueSetter(static_cast(value)); + } else { + SETTINGS.*(setting.valuePtr) = static_cast(value); + } + updatedCount++; + } + break; + } + + case SettingType::STRING: { + const char* value = doc[setting.key].as(); + if (value != nullptr) { + if (setting.stringSetter) { + setting.stringSetter(value); + } else { + strncpy(setting.stringPtr, value, setting.stringMaxLen); + setting.stringPtr[setting.stringMaxLen] = '\0'; + } + updatedCount++; + } + break; + } + + case SettingType::ACTION: + break; + } + } + + if (updatedCount > 0) { + SETTINGS.saveToFile(); + KOREADER_STORE.saveToFile(); + Serial.printf("[%lu] [WEB] Updated %d settings and saved to file\n", millis(), updatedCount); + } + + server->send(200, "text/plain", "Settings updated: " + String(updatedCount)); +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 36030292..d2306bad 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -78,4 +78,9 @@ class CrossPointWebServer { void handleUploadPost() const; void handleCreateFolder() const; void handleDelete() const; + + // Settings handlers + void handleSettingsPage() const; + void handleGetSettings() const; + void handlePostSettings(); }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 95993b8e..4a9a7f9a 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -575,6 +575,7 @@