diff --git a/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx b/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx index e568f1e..b97eebd 100644 Binary files a/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx and b/.cache/clangd/index/Fonts.cpp.63891DD6EC3699BC.idx differ diff --git a/.cache/clangd/index/RoomCardBubbleWidget.cpp.0672D353A93A1667.idx b/.cache/clangd/index/RoomCardBubbleWidget.cpp.0672D353A93A1667.idx new file mode 100644 index 0000000..0290589 Binary files /dev/null and b/.cache/clangd/index/RoomCardBubbleWidget.cpp.0672D353A93A1667.idx differ diff --git a/.cache/clangd/index/RoomCardBubbleWidget.hpp.F5F106CD3B4439EA.idx b/.cache/clangd/index/RoomCardBubbleWidget.hpp.F5F106CD3B4439EA.idx new file mode 100644 index 0000000..b7eb307 Binary files /dev/null and b/.cache/clangd/index/RoomCardBubbleWidget.hpp.F5F106CD3B4439EA.idx differ diff --git a/.cache/clangd/index/RoomCardTileWidget.cpp.4F5E3794B098F0B3.idx b/.cache/clangd/index/RoomCardTileWidget.cpp.4F5E3794B098F0B3.idx new file mode 100644 index 0000000..09d0921 Binary files /dev/null and b/.cache/clangd/index/RoomCardTileWidget.cpp.4F5E3794B098F0B3.idx differ diff --git a/.cache/clangd/index/RoomCardTileWidget.hpp.D006F134C019997A.idx b/.cache/clangd/index/RoomCardTileWidget.hpp.D006F134C019997A.idx new file mode 100644 index 0000000..decdc54 Binary files /dev/null and b/.cache/clangd/index/RoomCardTileWidget.hpp.D006F134C019997A.idx differ diff --git a/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx b/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx index cb965a0..f3d6ca8 100644 Binary files a/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx and b/.cache/clangd/index/RoomCardWidget.cpp.FA613078500AD19C.idx differ diff --git a/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx b/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx index 9264b46..4b7808f 100644 Binary files a/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx and b/.cache/clangd/index/RoomCardWidget.hpp.DE0E3E44BD9BC30A.idx differ diff --git a/.cache/clangd/index/RoomCardWidgetBase.cpp.E82CB8390DB7EE04.idx b/.cache/clangd/index/RoomCardWidgetBase.cpp.E82CB8390DB7EE04.idx new file mode 100644 index 0000000..428d613 Binary files /dev/null and b/.cache/clangd/index/RoomCardWidgetBase.cpp.E82CB8390DB7EE04.idx differ diff --git a/.cache/clangd/index/RoomCardWidgetBase.hpp.7649988C886B5357.idx b/.cache/clangd/index/RoomCardWidgetBase.hpp.7649988C886B5357.idx new file mode 100644 index 0000000..7f165ae Binary files /dev/null and b/.cache/clangd/index/RoomCardWidgetBase.hpp.7649988C886B5357.idx differ diff --git a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx index f2bbd2a..b1ae1f0 100644 Binary files a/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx and b/.cache/clangd/index/WidgetConfig.cpp.FD56F9F36C29A5DA.idx differ diff --git a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx index c4b43eb..d2ca489 100644 Binary files a/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx and b/.cache/clangd/index/WidgetConfig.hpp.CAEFE2EEEB2A6996.idx differ diff --git a/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx b/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx index 54dfea7..6bad1a2 100644 Binary files a/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx and b/.cache/clangd/index/WidgetFactory.cpp.1026CAEFCA630F22.idx differ diff --git a/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx b/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx index 4d2b1d9..6e038f1 100644 Binary files a/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx and b/.cache/clangd/index/WidgetManager.cpp.D8CE609DC911F13E.idx differ diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index b3ca56b..307717e 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -12,7 +12,9 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c "widgets/PowerLinkWidget.cpp" "widgets/ChartWidget.cpp" "widgets/ClockWidget.cpp" - "widgets/RoomCardWidget.cpp" + "widgets/RoomCardWidgetBase.cpp" + "widgets/RoomCardBubbleWidget.cpp" + "widgets/RoomCardTileWidget.cpp" "webserver/WebServer.cpp" "webserver/StaticFileHandlers.cpp" "webserver/ConfigHandlers.cpp" diff --git a/main/Fonts.cpp b/main/Fonts.cpp index b1206bc..7ef2498 100644 --- a/main/Fonts.cpp +++ b/main/Fonts.cpp @@ -17,8 +17,13 @@ static constexpr const char* kIconFontPath = "/sdcard/fonts/MaterialSymbolsOutli static constexpr uint16_t kFontSizes[] = {14, 18, 22, 28, 36, 48}; static constexpr size_t kFontCount = sizeof(kFontSizes) / sizeof(kFontSizes[0]); +// Icon fonts have more sizes including large ones for decorative use +// Large icons (>150px) use PSRAM for draw buffers via CLIB_MALLOC +static constexpr uint16_t kIconFontSizes[] = {14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260}; +static constexpr size_t kIconFontCount = sizeof(kIconFontSizes) / sizeof(kIconFontSizes[0]); + static const lv_font_t* s_fonts[kFontCount] = {nullptr}; -static const lv_font_t* s_iconFonts[kFontCount] = {nullptr}; +static const lv_font_t* s_iconFonts[kIconFontCount] = {nullptr}; static bool s_initialized = false; static bool s_iconFontAvailable = false; @@ -109,14 +114,14 @@ void Fonts::init() { } ESP_LOGI(TAG, "Text font initialization complete"); - // Load icon font if available + // Load icon font if available (includes larger sizes for decorative icons) if (fontFileExists(kIconFontPath)) { ESP_LOGI(TAG, "Icon font file exists: %s", kIconFontPath); - for (size_t i = 0; i < kFontCount; ++i) { - ESP_LOGI(TAG, "Loading icon font size %u...", kFontSizes[i]); + for (size_t i = 0; i < kIconFontCount; ++i) { + ESP_LOGI(TAG, "Loading icon font size %u...", kIconFontSizes[i]); esp_lv_adapter_ft_font_config_t cfg = {}; cfg.name = kIconFontPath; - cfg.size = kFontSizes[i]; + cfg.size = kIconFontSizes[i]; cfg.style = ESP_LV_ADAPTER_FT_FONT_STYLE_NORMAL; cfg.mem = nullptr; cfg.mem_size = 0; @@ -131,10 +136,10 @@ void Fonts::init() { s_iconFonts[i] = esp_lv_adapter_ft_font_get(handle); if (s_iconFonts[i]) { s_iconFontAvailable = true; - ESP_LOGI(TAG, "Icon font size %u loaded successfully", kFontSizes[i]); + ESP_LOGI(TAG, "Icon font size %u loaded successfully", kIconFontSizes[i]); } } - ESP_LOGI(TAG, "Icon font initialization complete"); + ESP_LOGI(TAG, "Icon font initialization complete (%zu sizes)", kIconFontCount); } else { ESP_LOGW(TAG, "Icon font file not found: %s", kIconFontPath); } @@ -154,18 +159,18 @@ const lv_font_t* Fonts::bySizeIndex(uint8_t sizeIndex) { const lv_font_t* Fonts::iconFont(uint8_t sizeIndex) { #if CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE - if (sizeIndex < kFontCount && s_iconFonts[sizeIndex]) { + if (sizeIndex < kIconFontCount && s_iconFonts[sizeIndex]) { return s_iconFonts[sizeIndex]; } // Try to return any available icon font size as fallback if (s_iconFontAvailable) { - for (size_t i = 0; i < kFontCount; ++i) { + for (size_t i = 0; i < kIconFontCount; ++i) { if (s_iconFonts[i]) return s_iconFonts[i]; } } #endif // No icon font available - return text font as last resort - return fallbackFont(sizeIndex); + return fallbackFont(sizeIndex < kFontCount ? sizeIndex : kFontCount - 1); } bool Fonts::hasIconFont() { diff --git a/main/WidgetConfig.cpp b/main/WidgetConfig.cpp index b732e00..36e2a92 100644 --- a/main/WidgetConfig.cpp +++ b/main/WidgetConfig.cpp @@ -47,7 +47,11 @@ void WidgetConfig::serialize(uint8_t* buf) const { buf[pos++] = iconPosition; buf[pos++] = iconSize; buf[pos++] = static_cast(iconGap); - + buf[pos++] = iconPositionX & 0xFF; + buf[pos++] = (iconPositionX >> 8) & 0xFF; + buf[pos++] = iconPositionY & 0xFF; + buf[pos++] = (iconPositionY >> 8) & 0xFF; + // Hierarchy buf[pos++] = static_cast(parentId); @@ -178,7 +182,11 @@ void WidgetConfig::deserialize(const uint8_t* buf) { iconPosition = buf[pos++]; iconSize = buf[pos++]; iconGap = static_cast(buf[pos++]); - + iconPositionX = static_cast(buf[pos] | (buf[pos+1] << 8)); + pos += 2; + iconPositionY = static_cast(buf[pos] | (buf[pos+1] << 8)); + pos += 2; + // Hierarchy parentId = static_cast(buf[pos++]); @@ -382,6 +390,8 @@ WidgetConfig WidgetConfig::createButton(uint8_t id, int16_t x, int16_t y, cfg.iconPosition = 0; cfg.iconSize = 1; cfg.iconGap = 8; + cfg.iconPositionX = 0; + cfg.iconPositionY = 0; // Secondary/tertiary address defaults cfg.knxAddress2 = 0; cfg.textSource2 = TextSource::STATIC; diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index 4680094..44fbea9 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -14,6 +14,7 @@ static constexpr size_t CHART_MAX_SERIES = 3; static constexpr size_t MAX_CONDITIONS = 3; static constexpr size_t MAX_FORMAT_LEN = 16; // Shorter format strings for left/right values static constexpr size_t MAX_SUBBUTTONS = 6; // Sub-buttons for RoomCard +static constexpr size_t MAX_TEXTLINES = 5; // Variable text lines for RoomCard enum class WidgetType : uint8_t { LABEL = 0, @@ -180,6 +181,18 @@ struct SubButtonConfig { // Total: 20 bytes per SubButton }; +// Text line configuration for RoomCard (24 bytes) +struct TextLineConfig { + uint32_t iconCodepoint; // 4 bytes - Icon codepoint (0 = no icon) + uint16_t knxAddr; // 2 bytes - KNX address for dynamic value + TextSource textSrc; // 1 byte - Text source (static or KNX DPT) + uint8_t fontSize; // 1 byte - Font size index (0-5) + bool enabled; // 1 byte - Is this text line active? + uint8_t _padding; // 1 byte - Alignment padding + char text[MAX_FORMAT_LEN]; // 16 bytes - Static text or format string + // Total: 26 bytes per TextLine +}; + // Shadow configuration struct ShadowConfig { int8_t offsetX; @@ -225,8 +238,10 @@ struct WidgetConfig { // Icon properties (for Label, Button, Icon widgets) uint32_t iconCodepoint; // Unicode codepoint (0 = no icon) uint8_t iconPosition; // IconPosition: 0=left, 1=right, 2=top, 3=bottom - uint8_t iconSize; // Font size index (0-5), same as fontSize + uint8_t iconSize; // Font size index (0-13 for icons) int8_t iconGap; // Gap between icon and text (px) + int16_t iconPositionX; // Icon Pos X (for decorative icons in Tile) + int16_t iconPositionY; // Icon Pos Y (for decorative icons in Tile) // Hierarchy int8_t parentId; // ID of parent widget (-1 = root/screen) @@ -260,9 +275,13 @@ struct WidgetConfig { uint8_t cardStyle; // 0=Bubble (round), 1=Tile (rectangular) SubButtonConfig subButtons[MAX_SUBBUTTONS]; + // RoomCard text lines (for Tile style) + uint8_t textLineCount; + TextLineConfig textLines[MAX_TEXTLINES]; + // Serialization size (fixed for NVS storage) - // 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 1 (subButtonOpacity) + 1 (cardStyle) + 120 (6 subButtons * 20) = 322 - static constexpr size_t SERIALIZED_SIZE = 322; + // 197 + 4 (iconPositionX/Y) + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 1 (subButtonOpacity) + 1 (cardStyle) + 120 (6 subButtons * 20) = 326 + static constexpr size_t SERIALIZED_SIZE = 326; void serialize(uint8_t* buf) const; void deserialize(const uint8_t* buf); diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index 696cabf..58135ee 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -1,6 +1,7 @@ #include "WidgetManager.hpp" #include "widgets/WidgetFactory.hpp" -#include "widgets/RoomCardWidget.hpp" +#include "widgets/RoomCardWidgetBase.hpp" +#include "widgets/RoomCardTileWidget.hpp" #include "HistoryStore.hpp" #include "SdCard.hpp" #include "esp_lv_adapter.h" @@ -1036,6 +1037,22 @@ void WidgetManager::applyCachedValuesToWidgets() { widget->onKnxValue3(value); } } + + // RoomCard text lines (only for Tile style) + if (widget->getType() == WidgetType::ROOMCARD) { + const WidgetConfig& cfg = widget->getConfig(); + if (cfg.cardStyle == 1 && cfg.textLineCount > 0) { + for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) { + const TextLineConfig& tl = cfg.textLines[i]; + if (tl.enabled && tl.knxAddr != 0 && tl.textSrc != TextSource::STATIC && isNumericTextSource(tl.textSrc)) { + float value = 0.0f; + if (getCachedKnxValue(tl.knxAddr, tl.textSrc, &value)) { + static_cast(widget.get())->onTextLineValue(i, value); + } + } + } + } + } } } @@ -1099,6 +1116,19 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so widget->getTextSource3() == source) { widget->onKnxValue3(value); } + + // RoomCard text line updates (only for Tile style) + if (widget->getType() == WidgetType::ROOMCARD) { + const WidgetConfig& cfg = widget->getConfig(); + if (cfg.cardStyle == 1 && cfg.textLineCount > 0) { + for (uint8_t i = 0; i < cfg.textLineCount && i < MAX_TEXTLINES; ++i) { + const TextLineConfig& tl = cfg.textLines[i]; + if (tl.enabled && tl.knxAddr == groupAddr && tl.textSrc == source) { + static_cast(widget.get())->onTextLineValue(i, value); + } + } + } + } } if (HistoryStore::instance().updateLatest(groupAddr, source, value)) { @@ -1119,7 +1149,7 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) { const WidgetConfig& cfg = widget->getConfig(); for (uint8_t i = 0; i < cfg.subButtonCount && i < MAX_SUBBUTTONS; ++i) { if (cfg.subButtons[i].enabled && cfg.subButtons[i].knxAddrRead == groupAddr) { - static_cast(widget.get())->onSubButtonStatus(i, value); + static_cast(widget.get())->onSubButtonStatus(i, value); } } } @@ -1514,6 +1544,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { cJSON_AddNumberToObject(widget, "iconPosition", w.iconPosition); cJSON_AddNumberToObject(widget, "iconSize", w.iconSize); cJSON_AddNumberToObject(widget, "iconGap", w.iconGap); + cJSON_AddNumberToObject(widget, "iconPositionX", w.iconPositionX); + cJSON_AddNumberToObject(widget, "iconPositionY", w.iconPositionY); cJSON_AddNumberToObject(widget, "parentId", w.parentId); @@ -1637,6 +1669,24 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const { } } + // RoomCard text lines + if (w.type == WidgetType::ROOMCARD && w.textLineCount > 0) { + cJSON* textLines = cJSON_AddArrayToObject(widget, "textLines"); + for (uint8_t ti = 0; ti < w.textLineCount && ti < MAX_TEXTLINES; ++ti) { + const TextLineConfig& tl = w.textLines[ti]; + if (!tl.enabled) continue; + + cJSON* tlJson = cJSON_CreateObject(); + cJSON_AddNumberToObject(tlJson, "icon", tl.iconCodepoint); + cJSON_AddNumberToObject(tlJson, "textSrc", static_cast(tl.textSrc)); + cJSON_AddNumberToObject(tlJson, "knxAddr", tl.knxAddr); + cJSON_AddStringToObject(tlJson, "text", tl.text); + cJSON_AddNumberToObject(tlJson, "fontSize", tl.fontSize); + + cJSON_AddItemToArray(textLines, tlJson); + } + } + cJSON_AddItemToArray(widgets, widget); } @@ -1798,6 +1848,12 @@ bool WidgetManager::updateConfigFromJson(const char* json) { cJSON* iconGap = cJSON_GetObjectItem(widget, "iconGap"); if (cJSON_IsNumber(iconGap)) w.iconGap = iconGap->valueint; + + cJSON* iconPositionX = cJSON_GetObjectItem(widget, "iconPositionX"); + if (cJSON_IsNumber(iconPositionX)) w.iconPositionX = iconPositionX->valueint; + + cJSON* iconPositionY = cJSON_GetObjectItem(widget, "iconPositionY"); + if (cJSON_IsNumber(iconPositionY)) w.iconPositionY = iconPositionY->valueint; cJSON* parentId = cJSON_GetObjectItem(widget, "parentId"); if (cJSON_IsNumber(parentId)) { @@ -1967,8 +2023,10 @@ bool WidgetManager::updateConfigFromJson(const char* json) { cJSON* cardStyle = cJSON_GetObjectItem(widget, "cardStyle"); if (cJSON_IsNumber(cardStyle)) { w.cardStyle = cardStyle->valueint; + ESP_LOGI(TAG, "RoomCard cardStyle loaded: %d", w.cardStyle); } else { w.cardStyle = 0; // Default to Bubble style + ESP_LOGI(TAG, "RoomCard cardStyle not found, using default 0"); } // RoomCard sub-buttons @@ -2028,6 +2086,51 @@ bool WidgetManager::updateConfigFromJson(const char* json) { w.subButtonCount = sbIdx; } + // RoomCard text lines + cJSON* textLines = cJSON_GetObjectItem(widget, "textLines"); + if (cJSON_IsArray(textLines)) { + uint8_t tlIdx = 0; + cJSON* tlItem = nullptr; + cJSON_ArrayForEach(tlItem, textLines) { + if (tlIdx >= MAX_TEXTLINES) break; + + TextLineConfig& tl = w.textLines[tlIdx]; + memset(&tl, 0, sizeof(tl)); + tl.enabled = true; + + cJSON* icon = cJSON_GetObjectItem(tlItem, "icon"); + if (cJSON_IsNumber(icon)) { + tl.iconCodepoint = static_cast(icon->valuedouble); + } + + cJSON* textSrc = cJSON_GetObjectItem(tlItem, "textSrc"); + if (cJSON_IsNumber(textSrc)) { + tl.textSrc = static_cast(textSrc->valueint); + } + + cJSON* knxAddr = cJSON_GetObjectItem(tlItem, "knxAddr"); + if (cJSON_IsNumber(knxAddr)) { + tl.knxAddr = knxAddr->valueint; + } + + cJSON* text = cJSON_GetObjectItem(tlItem, "text"); + if (cJSON_IsString(text)) { + strncpy(tl.text, text->valuestring, MAX_FORMAT_LEN - 1); + tl.text[MAX_FORMAT_LEN - 1] = '\0'; + } + + cJSON* fontSize = cJSON_GetObjectItem(tlItem, "fontSize"); + if (cJSON_IsNumber(fontSize)) { + tl.fontSize = fontSize->valueint; + } else { + tl.fontSize = 1; // Default 18px + } + + tlIdx++; + } + w.textLineCount = tlIdx; + } + screen.widgetCount++; } diff --git a/main/widgets/RoomCardBubbleWidget.cpp b/main/widgets/RoomCardBubbleWidget.cpp new file mode 100644 index 0000000..61a7c14 --- /dev/null +++ b/main/widgets/RoomCardBubbleWidget.cpp @@ -0,0 +1,144 @@ +#include "RoomCardBubbleWidget.hpp" +#include "../Fonts.hpp" +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +RoomCardBubbleWidget::RoomCardBubbleWidget(const WidgetConfig& config) + : RoomCardWidgetBase(config) +{ +} + +void RoomCardBubbleWidget::clearLvglObject() { + RoomCardWidgetBase::clearLvglObject(); + bubble_ = nullptr; + roomIcon_ = nullptr; +} + +lv_obj_t* RoomCardBubbleWidget::create(lv_obj_t* parent) { + parseTextBase(); + + screenParent_ = parent; + + // Create main container + obj_ = lv_obj_create(parent); + lv_obj_remove_style_all(obj_); + lv_obj_set_pos(obj_, config_.x, config_.y); + lv_obj_set_size(obj_, config_.width, config_.height); + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE); + + // Add click handler for navigation + lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this); + + createBubbleLayout(); + createSubButtonsCommon(); + + return obj_; +} + +void RoomCardBubbleWidget::createBubbleLayout() { + // Calculate bubble size (80% of widget size, circular) + int16_t minSide = config_.width < config_.height ? config_.width : config_.height; + int16_t bubbleSize = (minSide * 80) / 100; + + // Create bubble container (centered) + bubble_ = lv_obj_create(obj_); + lv_obj_remove_style_all(bubble_); + lv_obj_set_size(bubble_, bubbleSize, bubbleSize); + lv_obj_center(bubble_); + lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE); + + // Circular shape + lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0); + lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0); + + // Set up flex layout for bubble content + lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(bubble_, 8, 0); + lv_obj_set_style_pad_gap(bubble_, 2, 0); + + // Room icon + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + roomIcon_ = lv_label_create(bubble_); + lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(roomIcon_, iconText); + } + + // Temperature label + tempLabel_ = lv_label_create(bubble_); + lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : ""); + + // Room name label + if (roomName_[0] != '\0') { + roomLabel_ = lv_label_create(bubble_); + lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_text(roomLabel_, roomName_); + } + + // Click handler for navigation + lv_obj_add_event_cb(bubble_, cardClickCallback, LV_EVENT_CLICKED, this); +} + +void RoomCardBubbleWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) { + int16_t centerX = config_.width / 2; + int16_t centerY = config_.height / 2; + int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; + int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80; + + // Calculate angle: position 0=TOP, going clockwise, 8 positions = 45 degrees each + float angle = static_cast(static_cast(cfg.position)) * (M_PI / 4.0f) - (M_PI / 2.0f); + + x = centerX + static_cast(orbitRadius * cosf(angle)) - subBtnSize / 2; + y = centerY + static_cast(orbitRadius * sinf(angle)) - subBtnSize / 2; +} + +void RoomCardBubbleWidget::applyStyle() { + if (!obj_) return; + + lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); + + // Apply shadow to bubble if enabled + if (bubble_ && config_.shadow.enabled) { + lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0); + lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0); + lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0); + lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0); + lv_obj_set_style_shadow_opa(bubble_, 255, 0); + } + + // Font sizes + const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize); + const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize); + const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); + + // Style room icon + if (roomIcon_ && iconFont) { + lv_obj_set_style_text_font(roomIcon_, iconFont, 0); + lv_obj_set_style_text_color(roomIcon_, textColor, 0); + } + + // Style temperature + if (tempLabel_ && textFont) { + lv_obj_set_style_text_font(tempLabel_, textFont, 0); + lv_obj_set_style_text_color(tempLabel_, textColor, 0); + } + + // Style room label + if (roomLabel_ && labelFont) { + lv_obj_set_style_text_font(roomLabel_, labelFont, 0); + lv_obj_set_style_text_color(roomLabel_, textColor, 0); + lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed + } + + // Style sub-buttons + applySubButtonStyle(); +} diff --git a/main/widgets/RoomCardBubbleWidget.hpp b/main/widgets/RoomCardBubbleWidget.hpp new file mode 100644 index 0000000..42412c1 --- /dev/null +++ b/main/widgets/RoomCardBubbleWidget.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "RoomCardWidgetBase.hpp" + +class RoomCardBubbleWidget : public RoomCardWidgetBase { +public: + explicit RoomCardBubbleWidget(const WidgetConfig& config); + ~RoomCardBubbleWidget() override = default; + + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + void clearLvglObject() override; + +protected: + void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override; + +private: + // Bubble-specific elements + lv_obj_t* bubble_ = nullptr; + lv_obj_t* roomIcon_ = nullptr; + + void createBubbleLayout(); +}; diff --git a/main/widgets/RoomCardTileWidget.cpp b/main/widgets/RoomCardTileWidget.cpp new file mode 100644 index 0000000..2923a90 --- /dev/null +++ b/main/widgets/RoomCardTileWidget.cpp @@ -0,0 +1,222 @@ +#include "RoomCardTileWidget.hpp" +#include "../Fonts.hpp" +#include +#include + +RoomCardTileWidget::RoomCardTileWidget(const WidgetConfig& config) + : RoomCardWidgetBase(config) +{ + for (size_t i = 0; i < MAX_TEXTLINES; ++i) { + textLineLabels_[i] = nullptr; + } +} + +void RoomCardTileWidget::clearLvglObject() { + RoomCardWidgetBase::clearLvglObject(); + decorIcon_ = nullptr; + for (size_t i = 0; i < MAX_TEXTLINES; ++i) { + textLineLabels_[i] = nullptr; + } +} + +lv_obj_t* RoomCardTileWidget::create(lv_obj_t* parent) { + parseTextBase(); // Parses roomName_ from first line of text + + screenParent_ = parent; + + // Create main container + obj_ = lv_obj_create(parent); + lv_obj_remove_style_all(obj_); + lv_obj_set_pos(obj_, config_.x, config_.y); + lv_obj_set_size(obj_, config_.width, config_.height); + lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE); + + // Add click handler for navigation + lv_obj_add_event_cb(obj_, cardClickCallback, LV_EVENT_CLICKED, this); + + createTileLayout(); + createTextLines(); + createSubButtonsCommon(); + + return obj_; +} + +void RoomCardTileWidget::createTileLayout() { + // Tile style: rectangular card with rounded corners + int16_t radius = config_.borderRadius; + if (radius <= 0 || radius > 50) { + radius = 16; + } + lv_obj_set_style_radius(obj_, radius, 0); + lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0); + lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0); + lv_obj_set_style_clip_corner(obj_, true, 0); + + int16_t padding = 16; + + // Room name (top-left) + roomLabel_ = lv_label_create(obj_); + lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_text(roomLabel_, roomName_); + lv_obj_set_pos(roomLabel_, padding, padding); + + // Large decorative icon (bottom-left, partially visible) + if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { + decorIcon_ = lv_label_create(obj_); + lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(config_.iconCodepoint, iconText); + lv_label_set_text(decorIcon_, iconText); + + int16_t iconX = (config_.iconPositionX != 0) ? config_.iconPositionX : -20; + int16_t iconY = (config_.iconPositionY != 0) ? config_.iconPositionY : config_.height - 120; + lv_obj_set_pos(decorIcon_, iconX, iconY); + } +} + +void RoomCardTileWidget::createTextLines() { + if (config_.textLineCount == 0) return; + + int16_t padding = 16; + int16_t currentY = 48; // Below room name + + for (uint8_t i = 0; i < config_.textLineCount && i < MAX_TEXTLINES; ++i) { + const TextLineConfig& tl = config_.textLines[i]; + if (!tl.enabled) continue; + + // Get font for this line + uint8_t fontIdx = tl.fontSize; + if (fontIdx > 5) fontIdx = 1; // Default to 18px + const lv_font_t* lineFont = Fonts::bySizeIndex(fontIdx); + const lv_font_t* iconFont = Fonts::iconFont(fontIdx); + + // Create a row container + lv_obj_t* row = lv_obj_create(obj_); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_pos(row, padding, currentY); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(row, 6, 0); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(row, LV_OBJ_FLAG_CLICKABLE); + + // Create icon if set + if (tl.iconCodepoint > 0 && Fonts::hasIconFont()) { + lv_obj_t* icon = lv_label_create(row); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(tl.iconCodepoint, iconText); + lv_label_set_text(icon, iconText); + if (iconFont) { + lv_obj_set_style_text_font(icon, iconFont, 0); + } + lv_obj_set_style_text_color(icon, lv_color_hex(config_.textColor.toLvColor()), 0); + } + + // Create value label + lv_obj_t* label = lv_label_create(row); + lv_obj_clear_flag(label, LV_OBJ_FLAG_CLICKABLE); + + // Set initial text based on source + if (tl.textSrc == TextSource::STATIC) { + lv_label_set_text(label, tl.text); + } else { + lv_label_set_text(label, "--"); + } + + if (lineFont) { + lv_obj_set_style_text_font(label, lineFont, 0); + } + lv_obj_set_style_text_color(label, lv_color_hex(config_.textColor.toLvColor()), 0); + + textLineLabels_[i] = label; + + // Move Y position for next line + currentY += lv_font_get_line_height(lineFont) + 8; + } +} + +void RoomCardTileWidget::calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) { + (void)cfg; // Not used for Tile layout + + // Buttons are stacked vertically on the right side + int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; + int16_t gap = 10; + int16_t padding = 12; + + x = config_.width - subBtnSize - padding; + y = padding + index * (subBtnSize + gap); +} + +void RoomCardTileWidget::updateTextLineValue(uint8_t index, float value) { + if (index >= MAX_TEXTLINES || !textLineLabels_[index]) return; + + const TextLineConfig& tl = config_.textLines[index]; + if (tl.textSrc == TextSource::STATIC) return; + + char buf[32]; + if (tl.text[0] != '\0') { + snprintf(buf, sizeof(buf), tl.text, value); + } else { + // Default formats based on source type + switch (tl.textSrc) { + case TextSource::KNX_DPT_TEMP: + snprintf(buf, sizeof(buf), "%.1f C", value); + break; + case TextSource::KNX_DPT_PERCENT: + snprintf(buf, sizeof(buf), "%.0f %%", value); + break; + case TextSource::KNX_DPT_POWER: + snprintf(buf, sizeof(buf), "%.1f W", value); + break; + case TextSource::KNX_DPT_ENERGY: + snprintf(buf, sizeof(buf), "%.0f kWh", value); + break; + default: + snprintf(buf, sizeof(buf), "%.1f", value); + break; + } + } + lv_label_set_text(textLineLabels_[index], buf); +} + +void RoomCardTileWidget::onTextLineValue(uint8_t index, float value) { + updateTextLineValue(index, value); +} + +void RoomCardTileWidget::applyStyle() { + if (!obj_) return; + + lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); + + // Apply border if shadow is enabled (used as accent border) + if (config_.shadow.enabled) { + lv_obj_set_style_border_width(obj_, 3, 0); + lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0); + lv_obj_set_style_border_opa(obj_, 255, 0); + } + + // Room name - large font + if (roomLabel_) { + const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize); + lv_obj_set_style_text_font(roomLabel_, nameFont, 0); + lv_obj_set_style_text_color(roomLabel_, textColor, 0); + } + + // Large decorative icon + if (decorIcon_) { + uint8_t decorIconIdx = config_.iconSize; + if (decorIconIdx < 6) decorIconIdx = 8; // Default to 96px for decorative + const lv_font_t* bigIconFont = Fonts::iconFont(decorIconIdx); + if (bigIconFont) { + lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0); + lv_obj_set_style_text_color(decorIcon_, textColor, 0); + lv_obj_set_style_text_opa(decorIcon_, 50, 0); // Very transparent + } + } + + // Style sub-buttons + applySubButtonStyle(); +} diff --git a/main/widgets/RoomCardTileWidget.hpp b/main/widgets/RoomCardTileWidget.hpp new file mode 100644 index 0000000..afa47bd --- /dev/null +++ b/main/widgets/RoomCardTileWidget.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "RoomCardWidgetBase.hpp" + +class RoomCardTileWidget : public RoomCardWidgetBase { +public: + explicit RoomCardTileWidget(const WidgetConfig& config); + ~RoomCardTileWidget() override = default; + + lv_obj_t* create(lv_obj_t* parent) override; + void applyStyle() override; + void clearLvglObject() override; + + // Text line KNX updates + void onTextLineValue(uint8_t index, float value); + +protected: + void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) override; + +private: + // Tile-specific elements + lv_obj_t* decorIcon_ = nullptr; + + // Text line elements (rows with icon + label) + lv_obj_t* textLineLabels_[MAX_TEXTLINES] = {}; + + void createTileLayout(); + void createTextLines(); + void updateTextLineValue(uint8_t index, float value); +}; diff --git a/main/widgets/RoomCardWidget.cpp b/main/widgets/RoomCardWidget.cpp deleted file mode 100644 index b6347cd..0000000 --- a/main/widgets/RoomCardWidget.cpp +++ /dev/null @@ -1,622 +0,0 @@ -#include "RoomCardWidget.hpp" -#include "../Fonts.hpp" -#include "../WidgetManager.hpp" -#include -#include -#include - -#ifndef M_PI -#define M_PI 3.14159265358979323846 -#endif - -RoomCardWidget::RoomCardWidget(const WidgetConfig& config) - : Widget(config) -{ - roomName_[0] = '\0'; - tempFormat_[0] = '\0'; - subtitle_[0] = '\0'; - humidityFormat_[0] = '\0'; - for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { - subButtonStates_[i] = false; - } -} - -RoomCardWidget::~RoomCardWidget() { - // Sub-buttons are on screen parent, not on obj_, so delete them explicitly - for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { - if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) { - lv_obj_delete(subButtonObjs_[i]); - subButtonObjs_[i] = nullptr; - } - } -} - -void RoomCardWidget::clearLvglObject() { - // Clear sub-button pointers (they'll be deleted when screen is cleaned) - for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { - subButtonObjs_[i] = nullptr; - subButtonIcons_[i] = nullptr; - } - bubble_ = nullptr; - roomIcon_ = nullptr; - roomLabel_ = nullptr; - tempLabel_ = nullptr; - subtitleLabel_ = nullptr; - humidityLabel_ = nullptr; - decorIcon_ = nullptr; - obj_ = nullptr; -} - -int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) { - if (codepoint < 0x80) { - buf[0] = static_cast(codepoint); - buf[1] = '\0'; - return 1; - } else if (codepoint < 0x800) { - buf[0] = static_cast(0xC0 | (codepoint >> 6)); - buf[1] = static_cast(0x80 | (codepoint & 0x3F)); - buf[2] = '\0'; - return 2; - } else if (codepoint < 0x10000) { - buf[0] = static_cast(0xE0 | (codepoint >> 12)); - buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); - buf[2] = static_cast(0x80 | (codepoint & 0x3F)); - buf[3] = '\0'; - return 3; - } else if (codepoint < 0x110000) { - buf[0] = static_cast(0xF0 | (codepoint >> 18)); - buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); - buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); - buf[3] = static_cast(0x80 | (codepoint & 0x3F)); - buf[4] = '\0'; - return 4; - } - buf[0] = '\0'; - return 0; -} - -void RoomCardWidget::parseText() { - roomName_[0] = '\0'; - tempFormat_[0] = '\0'; - subtitle_[0] = '\0'; - humidityFormat_[0] = '\0'; - - // Format for Bubble: "RoomName\nTempFormat" - // Format for Tile: "RoomName\nSubtitle" (temp/humidity via text2/text3) - const char* text = config_.text; - if (text && text[0] != '\0') { - const char* newline = strchr(text, '\n'); - if (newline) { - size_t nameLen = static_cast(newline - text); - if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1; - memcpy(roomName_, text, nameLen); - roomName_[nameLen] = '\0'; - - if (config_.cardStyle == 0) { - // Bubble: second line is temp format - strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1); - tempFormat_[MAX_TEXT_LEN - 1] = '\0'; - } else { - // Tile: second line is subtitle - strncpy(subtitle_, newline + 1, MAX_TEXT_LEN - 1); - subtitle_[MAX_TEXT_LEN - 1] = '\0'; - } - } else { - strncpy(roomName_, text, MAX_TEXT_LEN - 1); - roomName_[MAX_TEXT_LEN - 1] = '\0'; - } - } - - // For Tile style, use text2 for temp format and text3 for humidity - if (config_.cardStyle == 1) { - if (config_.text2[0] != '\0') { - strncpy(tempFormat_, config_.text2, MAX_FORMAT_LEN - 1); - tempFormat_[MAX_FORMAT_LEN - 1] = '\0'; - } - if (config_.text3[0] != '\0') { - strncpy(humidityFormat_, config_.text3, MAX_FORMAT_LEN - 1); - humidityFormat_[MAX_FORMAT_LEN - 1] = '\0'; - } - } -} - -void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) { - int16_t centerX = config_.width / 2; - int16_t centerY = config_.height / 2; - - // Sub-button size (configurable, default 40) - int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; - - // Sub-buttons orbit radius in pixels (default 80) - int16_t orbitRadius = config_.subButtonDistance > 0 ? config_.subButtonDistance : 80; - - // Calculate angle: 0=TOP, going clockwise, 8 positions = 45 degrees each - float angle = static_cast(static_cast(pos)) * (M_PI / 4.0f) - (M_PI / 2.0f); - - // Calculate position (center of sub-button) - x = centerX + static_cast(orbitRadius * cosf(angle)) - subBtnSize / 2; - y = centerY + static_cast(orbitRadius * sinf(angle)) - subBtnSize / 2; -} - -void RoomCardWidget::calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y) { - // For Tile layout: buttons are stacked vertically on the right side - int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; - int16_t gap = 10; // Gap between buttons - int16_t padding = 12; // Padding from edge - - x = config_.width - subBtnSize - padding; - y = padding + index * (subBtnSize + gap); -} - -lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) { - parseText(); - - // Store parent for sub-buttons (they're created on screen level to avoid clipping) - screenParent_ = parent; - - // Create main container - obj_ = lv_obj_create(parent); - lv_obj_remove_style_all(obj_); - lv_obj_set_pos(obj_, config_.x, config_.y); - lv_obj_set_size(obj_, config_.width, config_.height); - lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE); - - // Add click handler for navigation - lv_obj_add_event_cb(obj_, bubbleClickCallback, LV_EVENT_CLICKED, this); - - // Create layout based on card style - if (config_.cardStyle == 1) { - createTileLayout(); - createSubButtonsTile(); - } else { - createBubbleLayout(); - createSubButtons(); - } - - return obj_; -} - -void RoomCardWidget::createBubbleLayout() { - // Calculate bubble size (80% of widget size, circular) - int16_t minSide = config_.width < config_.height ? config_.width : config_.height; - int16_t bubbleSize = (minSide * 80) / 100; - - // Create bubble container (centered) - bubble_ = lv_obj_create(obj_); - lv_obj_remove_style_all(bubble_); - lv_obj_set_size(bubble_, bubbleSize, bubbleSize); - lv_obj_center(bubble_); - lv_obj_clear_flag(bubble_, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(bubble_, LV_OBJ_FLAG_CLICKABLE); - - // Circular shape - lv_obj_set_style_radius(bubble_, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(bubble_, config_.bgOpacity, 0); - lv_obj_set_style_bg_color(bubble_, lv_color_hex(config_.bgColor.toLvColor()), 0); - - // Set up flex layout for bubble content - lv_obj_set_flex_flow(bubble_, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(bubble_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(bubble_, 8, 0); - lv_obj_set_style_pad_gap(bubble_, 2, 0); - - // Room icon - if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { - roomIcon_ = lv_label_create(bubble_); - lv_obj_clear_flag(roomIcon_, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(config_.iconCodepoint, iconText); - lv_label_set_text(roomIcon_, iconText); - } - - // Temperature label - tempLabel_ = lv_label_create(bubble_); - lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(tempLabel_, tempFormat_[0] != '\0' ? "--" : ""); - - // Room name label - if (roomName_[0] != '\0') { - roomLabel_ = lv_label_create(bubble_); - lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(roomLabel_, roomName_); - } - - // Click handler for navigation - lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this); -} - -void RoomCardWidget::createTileLayout() { - // Tile style: rectangular card with rounded corners - lv_obj_set_style_radius(obj_, config_.borderRadius > 0 ? config_.borderRadius : 16, 0); - lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0); - lv_obj_set_style_bg_color(obj_, lv_color_hex(config_.bgColor.toLvColor()), 0); - lv_obj_set_style_clip_corner(obj_, true, 0); - - int16_t padding = 16; - - // Room name (top-left, large) - roomLabel_ = lv_label_create(obj_); - lv_obj_clear_flag(roomLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(roomLabel_, roomName_); - lv_obj_set_pos(roomLabel_, padding, padding); - - // Subtitle (below room name, smaller) - if (subtitle_[0] != '\0') { - subtitleLabel_ = lv_label_create(obj_); - lv_obj_clear_flag(subtitleLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(subtitleLabel_, subtitle_); - lv_obj_set_pos(subtitleLabel_, padding, padding + 28); - } - - // Temperature row (with thermometer icon) - if (tempFormat_[0] != '\0') { - // Container for temp icon + value - lv_obj_t* tempRow = lv_obj_create(obj_); - lv_obj_remove_style_all(tempRow); - lv_obj_set_size(tempRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_pos(tempRow, padding, padding + 56); - lv_obj_set_flex_flow(tempRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(tempRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_gap(tempRow, 4, 0); - lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(tempRow, LV_OBJ_FLAG_CLICKABLE); - - // Thermometer icon (U+E1FF or similar) - if (Fonts::hasIconFont()) { - lv_obj_t* tempIcon = lv_label_create(tempRow); - lv_obj_clear_flag(tempIcon, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(0xf076, iconText); // thermometer icon - lv_label_set_text(tempIcon, iconText); - } - - tempLabel_ = lv_label_create(tempRow); - lv_obj_clear_flag(tempLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(tempLabel_, "--"); - } - - // Humidity row (with water drop icon) - if (humidityFormat_[0] != '\0') { - lv_obj_t* humRow = lv_obj_create(obj_); - lv_obj_remove_style_all(humRow); - lv_obj_set_size(humRow, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_pos(humRow, padding + 80, padding + 56); // Next to temp - lv_obj_set_flex_flow(humRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(humRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_gap(humRow, 4, 0); - lv_obj_clear_flag(humRow, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_clear_flag(humRow, LV_OBJ_FLAG_CLICKABLE); - - // Water drop icon - if (Fonts::hasIconFont()) { - lv_obj_t* humIcon = lv_label_create(humRow); - lv_obj_clear_flag(humIcon, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(0xe798, iconText); // humidity/water icon - lv_label_set_text(humIcon, iconText); - } - - humidityLabel_ = lv_label_create(humRow); - lv_obj_clear_flag(humidityLabel_, LV_OBJ_FLAG_CLICKABLE); - lv_label_set_text(humidityLabel_, "--"); - } - - // Large decorative icon (bottom-left, partially visible) - if (config_.iconCodepoint > 0 && Fonts::hasIconFont()) { - decorIcon_ = lv_label_create(obj_); - lv_obj_clear_flag(decorIcon_, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(config_.iconCodepoint, iconText); - lv_label_set_text(decorIcon_, iconText); - // Position at bottom-left, slightly outside - lv_obj_set_pos(decorIcon_, -20, config_.height - 100); - } -} - -void RoomCardWidget::createSubButtons() { - // Sub-button size (configurable, default 40) - int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; - - for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { - const SubButtonConfig& cfg = config_.subButtons[i]; - if (!cfg.enabled) continue; - - // Create sub-button on screen parent (not on widget container) to avoid clipping - lv_obj_t* btn = lv_obj_create(screenParent_); - lv_obj_remove_style_all(btn); - lv_obj_set_size(btn, subBtnSize, subBtnSize); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); - - // Circular shape - lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0); - - // Position using circle geometry (relative to widget center) - int16_t relX, relY; - calculateSubButtonPosition(cfg.position, relX, relY); - // Convert to absolute screen position - int16_t absX = config_.x + relX; - int16_t absY = config_.y + relY; - lv_obj_set_pos(btn, absX, absY); - - // Create icon - if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { - lv_obj_t* icon = lv_label_create(btn); - lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(cfg.iconCodepoint, iconText); - lv_label_set_text(icon, iconText); - lv_obj_center(icon); - subButtonIcons_[i] = icon; - } - - // Store index in user_data for click handler - lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); - lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); - - subButtonObjs_[i] = btn; - subButtonStates_[i] = false; - - // Apply initial color (OFF state) - updateSubButtonColor(i); - } -} - -void RoomCardWidget::createSubButtonsTile() { - // For Tile layout: buttons are positioned on the right side, vertically stacked - int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; - - for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { - const SubButtonConfig& cfg = config_.subButtons[i]; - if (!cfg.enabled) continue; - - // Create sub-button on screen parent - lv_obj_t* btn = lv_obj_create(screenParent_); - lv_obj_remove_style_all(btn); - lv_obj_set_size(btn, subBtnSize, subBtnSize); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); - - // Circular shape - lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0); - - // Position on right side - int16_t relX, relY; - calculateSubButtonPositionTile(i, relX, relY); - int16_t absX = config_.x + relX; - int16_t absY = config_.y + relY; - lv_obj_set_pos(btn, absX, absY); - - // Create icon - if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { - lv_obj_t* icon = lv_label_create(btn); - lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); - char iconText[5]; - encodeUtf8(cfg.iconCodepoint, iconText); - lv_label_set_text(icon, iconText); - lv_obj_center(icon); - subButtonIcons_[i] = icon; - } - - // Store index in user_data for click handler - lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); - lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); - - subButtonObjs_[i] = btn; - subButtonStates_[i] = false; - - // Apply initial color (OFF state) - updateSubButtonColor(i); - } -} - -void RoomCardWidget::applyStyle() { - if (!obj_) return; - - lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); - - if (config_.cardStyle == 1) { - // Tile style - // Apply border if shadow is enabled (used as accent border) - if (config_.shadow.enabled) { - lv_obj_set_style_border_width(obj_, 3, 0); - lv_obj_set_style_border_color(obj_, lv_color_hex(config_.shadow.color.toLvColor()), 0); - lv_obj_set_style_border_opa(obj_, 255, 0); - } - - // Room name - large font - if (roomLabel_) { - const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize); - lv_obj_set_style_text_font(roomLabel_, nameFont, 0); - lv_obj_set_style_text_color(roomLabel_, textColor, 0); - } - - // Subtitle - smaller, dimmed - if (subtitleLabel_) { - const lv_font_t* subFont = Fonts::bySizeIndex(config_.fontSize > 1 ? config_.fontSize - 2 : 0); - lv_obj_set_style_text_font(subtitleLabel_, subFont, 0); - lv_obj_set_style_text_color(subtitleLabel_, textColor, 0); - lv_obj_set_style_text_opa(subtitleLabel_, 150, 0); - } - - // Temp and humidity - medium font - const lv_font_t* valueFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); - const lv_font_t* iconFont = Fonts::iconFont(1); // Small icons - - if (tempLabel_) { - lv_obj_set_style_text_font(tempLabel_, valueFont, 0); - lv_obj_set_style_text_color(tempLabel_, textColor, 0); - // Style the icon in the same row - lv_obj_t* parent = lv_obj_get_parent(tempLabel_); - if (parent && lv_obj_get_child_count(parent) > 0) { - lv_obj_t* iconLabel = lv_obj_get_child(parent, 0); - if (iconLabel && iconFont) { - lv_obj_set_style_text_font(iconLabel, iconFont, 0); - lv_obj_set_style_text_color(iconLabel, textColor, 0); - } - } - } - - if (humidityLabel_) { - lv_obj_set_style_text_font(humidityLabel_, valueFont, 0); - lv_obj_set_style_text_color(humidityLabel_, textColor, 0); - lv_obj_t* parent = lv_obj_get_parent(humidityLabel_); - if (parent && lv_obj_get_child_count(parent) > 0) { - lv_obj_t* iconLabel = lv_obj_get_child(parent, 0); - if (iconLabel && iconFont) { - lv_obj_set_style_text_font(iconLabel, iconFont, 0); - lv_obj_set_style_text_color(iconLabel, textColor, 0); - } - } - } - - // Large decorative icon - if (decorIcon_) { - const lv_font_t* bigIconFont = Fonts::iconFont(5); // Largest icon - if (bigIconFont) { - lv_obj_set_style_text_font(decorIcon_, bigIconFont, 0); - lv_obj_set_style_text_color(decorIcon_, textColor, 0); - lv_obj_set_style_text_opa(decorIcon_, 60, 0); // Very transparent - } - } - } else { - // Bubble style - // Apply shadow to bubble if enabled - if (bubble_ && config_.shadow.enabled) { - lv_obj_set_style_shadow_width(bubble_, config_.shadow.blur, 0); - lv_obj_set_style_shadow_ofs_x(bubble_, config_.shadow.offsetX, 0); - lv_obj_set_style_shadow_ofs_y(bubble_, config_.shadow.offsetY, 0); - lv_obj_set_style_shadow_color(bubble_, lv_color_hex(config_.shadow.color.toLvColor()), 0); - lv_obj_set_style_shadow_opa(bubble_, 255, 0); - } - - // Font sizes - const lv_font_t* iconFont = Fonts::iconFont(config_.iconSize); - const lv_font_t* textFont = Fonts::bySizeIndex(config_.fontSize); - const lv_font_t* labelFont = Fonts::bySizeIndex(config_.fontSize > 0 ? config_.fontSize - 1 : 0); - - // Style room icon - if (roomIcon_ && iconFont) { - lv_obj_set_style_text_font(roomIcon_, iconFont, 0); - lv_obj_set_style_text_color(roomIcon_, textColor, 0); - } - - // Style temperature - if (tempLabel_ && textFont) { - lv_obj_set_style_text_font(tempLabel_, textFont, 0); - lv_obj_set_style_text_color(tempLabel_, textColor, 0); - } - - // Style room label - if (roomLabel_ && labelFont) { - lv_obj_set_style_text_font(roomLabel_, labelFont, 0); - lv_obj_set_style_text_color(roomLabel_, textColor, 0); - lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed - } - } - - // Style sub-buttons - adjust icon size based on button size - uint8_t subBtnFontIdx = 1; // Default small - if (config_.subButtonSize > 55) { - subBtnFontIdx = 3; - } else if (config_.subButtonSize > 40) { - subBtnFontIdx = 2; - } - const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx); - for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { - if (subButtonIcons_[i] && subBtnIconFont) { - lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0); - lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0); - } - } -} - -void RoomCardWidget::updateSubButtonColor(uint8_t index) { - if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return; - - const SubButtonConfig& cfg = config_.subButtons[index]; - const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff; - - lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0); -} - -void RoomCardWidget::updateTemperature(float value) { - if (!tempLabel_ || tempFormat_[0] == '\0') return; - - char buf[32]; - snprintf(buf, sizeof(buf), tempFormat_, value); - lv_label_set_text(tempLabel_, buf); -} - -void RoomCardWidget::updateHumidity(float value) { - if (!humidityLabel_ || humidityFormat_[0] == '\0') return; - - char buf[32]; - snprintf(buf, sizeof(buf), humidityFormat_, value); - lv_label_set_text(humidityLabel_, buf); -} - -void RoomCardWidget::onKnxValue(float value) { - cachedPrimaryValue_ = value; - hasCachedPrimary_ = true; - updateTemperature(value); -} - -void RoomCardWidget::onKnxSwitch(bool value) { - // Not used directly - sub-button status is handled via onSubButtonStatus - (void)value; -} - -void RoomCardWidget::onSubButtonStatus(uint8_t index, bool value) { - if (index >= MAX_SUBBUTTONS) return; - - subButtonStates_[index] = value; - updateSubButtonColor(index); -} - -void RoomCardWidget::sendSubButtonToggle(uint8_t index) { - if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return; - - const SubButtonConfig& cfg = config_.subButtons[index]; - if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) { - bool newState = !subButtonStates_[index]; - // Send KNX toggle via WidgetManager - WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState); - // Optimistically update local state - subButtonStates_[index] = newState; - updateSubButtonColor(index); - } -} - -void RoomCardWidget::bubbleClickCallback(lv_event_t* e) { - RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); - if (!widget) return; - - // Handle navigation based on action - if (widget->config_.action == ButtonAction::JUMP) { - WidgetManager::instance().navigateToScreen(widget->config_.targetScreen); - } else if (widget->config_.action == ButtonAction::BACK) { - WidgetManager::instance().navigateBack(); - } -} - -void RoomCardWidget::subButtonClickCallback(lv_event_t* e) { - RoomCardWidget* widget = static_cast(lv_event_get_user_data(e)); - lv_obj_t* target = static_cast(lv_event_get_target(e)); - if (!widget || !target) return; - - uint8_t index = static_cast(reinterpret_cast(lv_obj_get_user_data(target))); - - if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) { - const SubButtonConfig& cfg = widget->config_.subButtons[index]; - - if (cfg.action == SubButtonAction::TOGGLE_KNX) { - widget->sendSubButtonToggle(index); - } else if (cfg.action == SubButtonAction::NAVIGATE) { - WidgetManager::instance().navigateToScreen(cfg.targetScreen); - } - } -} diff --git a/main/widgets/RoomCardWidget.hpp b/main/widgets/RoomCardWidget.hpp deleted file mode 100644 index 8d0daac..0000000 --- a/main/widgets/RoomCardWidget.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include "Widget.hpp" - -class RoomCardWidget : public Widget { -public: - explicit RoomCardWidget(const WidgetConfig& config); - ~RoomCardWidget() override; - lv_obj_t* create(lv_obj_t* parent) override; - void applyStyle() override; - void onKnxValue(float value) override; // Temperature update - void onKnxSwitch(bool value) override; // Not used directly - - // Sub-button specific handling - void onSubButtonStatus(uint8_t index, bool value); - void sendSubButtonToggle(uint8_t index); - - // Override to also clear sub-buttons - void clearLvglObject(); - -private: - // Parent (screen) for sub-buttons to avoid clipping - lv_obj_t* screenParent_ = nullptr; - - // Bubble style elements (cardStyle=0) - lv_obj_t* bubble_ = nullptr; - lv_obj_t* roomIcon_ = nullptr; - lv_obj_t* roomLabel_ = nullptr; - lv_obj_t* tempLabel_ = nullptr; - - // Tile style elements (cardStyle=1) - lv_obj_t* subtitleLabel_ = nullptr; - lv_obj_t* humidityLabel_ = nullptr; - lv_obj_t* decorIcon_ = nullptr; // Large decorative icon bottom-left - - // Sub-button elements - lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {}; - lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {}; - bool subButtonStates_[MAX_SUBBUTTONS] = {}; - - // Cached config values - char roomName_[MAX_TEXT_LEN] = {0}; - char tempFormat_[MAX_TEXT_LEN] = {0}; - char subtitle_[MAX_TEXT_LEN] = {0}; - char humidityFormat_[MAX_FORMAT_LEN] = {0}; - - // Layout helpers - void parseText(); - void createBubbleLayout(); // Bubble style (round) - void createTileLayout(); // Tile style (rectangular) - void createSubButtons(); - void createSubButtonsTile(); // SubButtons for tile (right side) - void updateSubButtonColor(uint8_t index); - void updateTemperature(float value); - void updateHumidity(float value); - - // Geometry calculations - void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y); - void calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y); - - // Event handlers - static void bubbleClickCallback(lv_event_t* e); - static void subButtonClickCallback(lv_event_t* e); - - // UTF-8 encoding helper - static int encodeUtf8(uint32_t codepoint, char* buf); -}; diff --git a/main/widgets/RoomCardWidgetBase.cpp b/main/widgets/RoomCardWidgetBase.cpp new file mode 100644 index 0000000..d8274ab --- /dev/null +++ b/main/widgets/RoomCardWidgetBase.cpp @@ -0,0 +1,231 @@ +#include "RoomCardWidgetBase.hpp" +#include "../Fonts.hpp" +#include "../WidgetManager.hpp" +#include "esp_log.h" +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +RoomCardWidgetBase::RoomCardWidgetBase(const WidgetConfig& config) + : Widget(config) +{ + roomName_[0] = '\0'; + tempFormat_[0] = '\0'; + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + subButtonStates_[i] = false; + } +} + +RoomCardWidgetBase::~RoomCardWidgetBase() { + // Sub-buttons are on screen parent, not on obj_, so delete them explicitly + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + if (subButtonObjs_[i] && lv_obj_is_valid(subButtonObjs_[i])) { + lv_obj_delete(subButtonObjs_[i]); + subButtonObjs_[i] = nullptr; + } + } +} + +void RoomCardWidgetBase::clearLvglObject() { + for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { + subButtonObjs_[i] = nullptr; + subButtonIcons_[i] = nullptr; + } + roomLabel_ = nullptr; + tempLabel_ = nullptr; + obj_ = nullptr; +} + +int RoomCardWidgetBase::encodeUtf8(uint32_t codepoint, char* buf) { + if (codepoint < 0x80) { + buf[0] = static_cast(codepoint); + buf[1] = '\0'; + return 1; + } else if (codepoint < 0x800) { + buf[0] = static_cast(0xC0 | (codepoint >> 6)); + buf[1] = static_cast(0x80 | (codepoint & 0x3F)); + buf[2] = '\0'; + return 2; + } else if (codepoint < 0x10000) { + buf[0] = static_cast(0xE0 | (codepoint >> 12)); + buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[2] = static_cast(0x80 | (codepoint & 0x3F)); + buf[3] = '\0'; + return 3; + } else if (codepoint < 0x110000) { + buf[0] = static_cast(0xF0 | (codepoint >> 18)); + buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + buf[3] = static_cast(0x80 | (codepoint & 0x3F)); + buf[4] = '\0'; + return 4; + } + buf[0] = '\0'; + return 0; +} + +void RoomCardWidgetBase::parseTextBase() { + roomName_[0] = '\0'; + tempFormat_[0] = '\0'; + + const char* text = config_.text; + if (text && text[0] != '\0') { + const char* newline = strchr(text, '\n'); + if (newline) { + size_t nameLen = static_cast(newline - text); + if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1; + memcpy(roomName_, text, nameLen); + roomName_[nameLen] = '\0'; + + strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1); + tempFormat_[MAX_TEXT_LEN - 1] = '\0'; + } else { + strncpy(roomName_, text, MAX_TEXT_LEN - 1); + roomName_[MAX_TEXT_LEN - 1] = '\0'; + } + } +} + +void RoomCardWidgetBase::createSubButtonsCommon() { + int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; + + for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { + const SubButtonConfig& cfg = config_.subButtons[i]; + if (!cfg.enabled) continue; + + // Create sub-button on screen parent (not on widget container) to avoid clipping + lv_obj_t* btn = lv_obj_create(screenParent_); + lv_obj_remove_style_all(btn); + lv_obj_set_size(btn, subBtnSize, subBtnSize); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); + + // Circular shape + lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0); + + // Position using layout-specific calculation + int16_t relX, relY; + calculateSubButtonPosition(i, cfg, relX, relY); + int16_t absX = config_.x + relX; + int16_t absY = config_.y + relY; + lv_obj_set_pos(btn, absX, absY); + + // Create icon + if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { + lv_obj_t* icon = lv_label_create(btn); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); + char iconText[5]; + encodeUtf8(cfg.iconCodepoint, iconText); + lv_label_set_text(icon, iconText); + lv_obj_center(icon); + subButtonIcons_[i] = icon; + } + + // Store index in user_data for click handler + lv_obj_set_user_data(btn, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(btn, subButtonClickCallback, LV_EVENT_CLICKED, this); + + subButtonObjs_[i] = btn; + subButtonStates_[i] = false; + + // Apply initial color (OFF state) + updateSubButtonColor(i); + } +} + +void RoomCardWidgetBase::applySubButtonStyle() { + uint8_t subBtnFontIdx = 1; // Default small + if (config_.subButtonSize > 55) { + subBtnFontIdx = 3; + } else if (config_.subButtonSize > 40) { + subBtnFontIdx = 2; + } + const lv_font_t* subBtnIconFont = Fonts::iconFont(subBtnFontIdx); + for (uint8_t i = 0; i < config_.subButtonCount && i < MAX_SUBBUTTONS; ++i) { + if (subButtonIcons_[i] && subBtnIconFont) { + lv_obj_set_style_text_font(subButtonIcons_[i], subBtnIconFont, 0); + lv_obj_set_style_text_color(subButtonIcons_[i], lv_color_white(), 0); + } + } +} + +void RoomCardWidgetBase::updateSubButtonColor(uint8_t index) { + if (index >= MAX_SUBBUTTONS || !subButtonObjs_[index]) return; + + const SubButtonConfig& cfg = config_.subButtons[index]; + const Color& color = subButtonStates_[index] ? cfg.colorOn : cfg.colorOff; + + lv_obj_set_style_bg_color(subButtonObjs_[index], lv_color_hex(color.toLvColor()), 0); +} + +void RoomCardWidgetBase::updateTemperature(float value) { + if (!tempLabel_ || tempFormat_[0] == '\0') return; + + char buf[32]; + snprintf(buf, sizeof(buf), tempFormat_, value); + lv_label_set_text(tempLabel_, buf); +} + +void RoomCardWidgetBase::onKnxValue(float value) { + cachedPrimaryValue_ = value; + hasCachedPrimary_ = true; + updateTemperature(value); +} + +void RoomCardWidgetBase::onKnxSwitch(bool value) { + // Not used directly - sub-button status is handled via onSubButtonStatus + (void)value; +} + +void RoomCardWidgetBase::onSubButtonStatus(uint8_t index, bool value) { + if (index >= MAX_SUBBUTTONS) return; + + subButtonStates_[index] = value; + updateSubButtonColor(index); +} + +void RoomCardWidgetBase::sendSubButtonToggle(uint8_t index) { + if (index >= config_.subButtonCount || index >= MAX_SUBBUTTONS) return; + + const SubButtonConfig& cfg = config_.subButtons[index]; + if (cfg.action == SubButtonAction::TOGGLE_KNX && cfg.knxAddrWrite > 0) { + bool newState = !subButtonStates_[index]; + WidgetManager::instance().sendKnxSwitch(cfg.knxAddrWrite, newState); + subButtonStates_[index] = newState; + updateSubButtonColor(index); + } +} + +void RoomCardWidgetBase::cardClickCallback(lv_event_t* e) { + RoomCardWidgetBase* widget = static_cast(lv_event_get_user_data(e)); + if (!widget) return; + + if (widget->config_.action == ButtonAction::JUMP) { + WidgetManager::instance().navigateToScreen(widget->config_.targetScreen); + } else if (widget->config_.action == ButtonAction::BACK) { + WidgetManager::instance().navigateBack(); + } +} + +void RoomCardWidgetBase::subButtonClickCallback(lv_event_t* e) { + RoomCardWidgetBase* widget = static_cast(lv_event_get_user_data(e)); + lv_obj_t* target = static_cast(lv_event_get_target(e)); + if (!widget || !target) return; + + uint8_t index = static_cast(reinterpret_cast(lv_obj_get_user_data(target))); + + if (index < widget->config_.subButtonCount && index < MAX_SUBBUTTONS) { + const SubButtonConfig& cfg = widget->config_.subButtons[index]; + + if (cfg.action == SubButtonAction::TOGGLE_KNX) { + widget->sendSubButtonToggle(index); + } else if (cfg.action == SubButtonAction::NAVIGATE) { + WidgetManager::instance().navigateToScreen(cfg.targetScreen); + } + } +} diff --git a/main/widgets/RoomCardWidgetBase.hpp b/main/widgets/RoomCardWidgetBase.hpp new file mode 100644 index 0000000..7c5af6b --- /dev/null +++ b/main/widgets/RoomCardWidgetBase.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "Widget.hpp" + +class RoomCardWidgetBase : public Widget { +public: + explicit RoomCardWidgetBase(const WidgetConfig& config); + ~RoomCardWidgetBase() override; + + void onKnxValue(float value) override; // Temperature update + void onKnxSwitch(bool value) override; // Not used directly + + // Sub-button specific handling + void onSubButtonStatus(uint8_t index, bool value); + void sendSubButtonToggle(uint8_t index); + + // Clear LVGL objects + virtual void clearLvglObject(); + +protected: + // Parent (screen) for sub-buttons to avoid clipping + lv_obj_t* screenParent_ = nullptr; + + // Common elements + lv_obj_t* roomLabel_ = nullptr; + lv_obj_t* tempLabel_ = nullptr; + + // Sub-button elements + lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {}; + lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {}; + bool subButtonStates_[MAX_SUBBUTTONS] = {}; + + // Cached config values + char roomName_[MAX_TEXT_LEN] = {0}; + char tempFormat_[MAX_TEXT_LEN] = {0}; + + // Common helpers + void parseTextBase(); + void updateSubButtonColor(uint8_t index); + void updateTemperature(float value); + + // Virtual methods for layout-specific sub-button positioning + virtual void calculateSubButtonPosition(uint8_t index, const SubButtonConfig& cfg, int16_t& x, int16_t& y) = 0; + void createSubButtonsCommon(); + void applySubButtonStyle(); + + // Event handlers + static void cardClickCallback(lv_event_t* e); + static void subButtonClickCallback(lv_event_t* e); + + // UTF-8 encoding helper + static int encodeUtf8(uint32_t codepoint, char* buf); +}; diff --git a/main/widgets/WidgetFactory.cpp b/main/widgets/WidgetFactory.cpp index 9ded5dd..c3a06ad 100644 --- a/main/widgets/WidgetFactory.cpp +++ b/main/widgets/WidgetFactory.cpp @@ -10,7 +10,8 @@ #include "PowerLinkWidget.hpp" #include "ChartWidget.hpp" #include "ClockWidget.hpp" -#include "RoomCardWidget.hpp" +#include "RoomCardBubbleWidget.hpp" +#include "RoomCardTileWidget.hpp" std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { if (!config.visible) return nullptr; @@ -39,7 +40,11 @@ std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { case WidgetType::CLOCK: return std::make_unique(config); case WidgetType::ROOMCARD: - return std::make_unique(config); + // cardStyle: 0=Bubble, 1=Tile + if (config.cardStyle == 1) { + return std::make_unique(config); + } + return std::make_unique(config); default: return nullptr; } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 9af871b..38a0926 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -10,8 +10,15 @@ CONFIG_LV_FONT_MONTSERRAT_48=y # Keep LVGL draw thread stack reasonable to avoid xTaskCreate failures CONFIG_LV_DRAW_THREAD_STACK_SIZE=32768 -# Increase LVGL heap to avoid draw task OOM with dynamic UI -CONFIG_LV_MEM_SIZE_KILOBYTES=128 +# Use C library malloc instead of LVGL builtin - enables PSRAM usage +CONFIG_LV_USE_BUILTIN_MALLOC=n +CONFIG_LV_USE_CLIB_MALLOC=y + +# Increase draw layer buffer for large icons (250px icons need ~120KB) +CONFIG_LV_DRAW_LAYER_SIMPLE_BUF_SIZE=131072 + +# Backup LVGL heap size (used if builtin malloc is enabled) +CONFIG_LV_MEM_SIZE_KILOBYTES=512 # Enable object names for LVGL debug mapping CONFIG_LV_USE_OBJ_NAME=y @@ -22,3 +29,10 @@ CONFIG_ESP_LVGL_ADAPTER_ENABLE_FREETYPE=y # Increase main task stack for FreeType (needs ~16KB+) CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 + +# PSRAM configuration for large allocations +# Allocations > 1KB go to PSRAM to avoid internal RAM exhaustion +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=1024 + +# Allow PSRAM as malloc target +CONFIG_SPIRAM_USE_MALLOC=y diff --git a/web-interface/src/components/CanvasArea.vue b/web-interface/src/components/CanvasArea.vue index 87eb246..45a741b 100644 --- a/web-interface/src/components/CanvasArea.vue +++ b/web-interface/src/components/CanvasArea.vue @@ -282,7 +282,12 @@ function resizeDrag(e) { let newW = Math.round(rawW); let newH = Math.round(rawH); - if (w.type === WIDGET_TYPES.LED || w.type === WIDGET_TYPES.POWERNODE || w.type === WIDGET_TYPES.ROOMCARD) { + // Force square for LED, PowerNode, and RoomCard Bubble (not Tile) + const forceSquare = w.type === WIDGET_TYPES.LED || + w.type === WIDGET_TYPES.POWERNODE || + (w.type === WIDGET_TYPES.ROOMCARD && w.cardStyle !== 1); + + if (forceSquare) { const maxSize = Math.min(maxW, maxH); const size = clamp(Math.max(newW, newH), minSize.w, maxSize); newW = size; diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index 123a805..0aa0845 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -6,7 +6,7 @@ Waehle ein Widget im Canvas oder im Baum.
- +

Layout

@@ -14,640 +14,19 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +
- +
@@ -662,303 +41,114 @@ diff --git a/web-interface/src/components/WidgetElement.vue b/web-interface/src/components/WidgetElement.vue index c526945..1c4d1cf 100644 --- a/web-interface/src/components/WidgetElement.vue +++ b/web-interface/src/components/WidgetElement.vue @@ -1,868 +1,56 @@ diff --git a/web-interface/src/components/widgets/elements/ButtonElement.vue b/web-interface/src/components/widgets/elements/ButtonElement.vue new file mode 100644 index 0000000..1294e1c --- /dev/null +++ b/web-interface/src/components/widgets/elements/ButtonElement.vue @@ -0,0 +1,132 @@ + + + diff --git a/web-interface/src/components/widgets/elements/ChartElement.vue b/web-interface/src/components/widgets/elements/ChartElement.vue new file mode 100644 index 0000000..e0130ac --- /dev/null +++ b/web-interface/src/components/widgets/elements/ChartElement.vue @@ -0,0 +1,67 @@ + + + diff --git a/web-interface/src/components/widgets/elements/ClockElement.vue b/web-interface/src/components/widgets/elements/ClockElement.vue new file mode 100644 index 0000000..d98da7b --- /dev/null +++ b/web-interface/src/components/widgets/elements/ClockElement.vue @@ -0,0 +1,65 @@ + + + diff --git a/web-interface/src/components/widgets/elements/IconElement.vue b/web-interface/src/components/widgets/elements/IconElement.vue new file mode 100644 index 0000000..94e35e4 --- /dev/null +++ b/web-interface/src/components/widgets/elements/IconElement.vue @@ -0,0 +1,75 @@ + + + diff --git a/web-interface/src/components/widgets/elements/LabelElement.vue b/web-interface/src/components/widgets/elements/LabelElement.vue new file mode 100644 index 0000000..14f64c3 --- /dev/null +++ b/web-interface/src/components/widgets/elements/LabelElement.vue @@ -0,0 +1,112 @@ + + + diff --git a/web-interface/src/components/widgets/elements/LedElement.vue b/web-interface/src/components/widgets/elements/LedElement.vue new file mode 100644 index 0000000..f240ea0 --- /dev/null +++ b/web-interface/src/components/widgets/elements/LedElement.vue @@ -0,0 +1,63 @@ + + + diff --git a/web-interface/src/components/widgets/elements/PowerFlowElement.vue b/web-interface/src/components/widgets/elements/PowerFlowElement.vue new file mode 100644 index 0000000..c3d0f0c --- /dev/null +++ b/web-interface/src/components/widgets/elements/PowerFlowElement.vue @@ -0,0 +1,201 @@ + + + diff --git a/web-interface/src/components/widgets/elements/PowerNodeElement.vue b/web-interface/src/components/widgets/elements/PowerNodeElement.vue new file mode 100644 index 0000000..25af34e --- /dev/null +++ b/web-interface/src/components/widgets/elements/PowerNodeElement.vue @@ -0,0 +1,132 @@ + + + diff --git a/web-interface/src/components/widgets/elements/RoomCardElement.vue b/web-interface/src/components/widgets/elements/RoomCardElement.vue new file mode 100644 index 0000000..a16a9e4 --- /dev/null +++ b/web-interface/src/components/widgets/elements/RoomCardElement.vue @@ -0,0 +1,287 @@ + + + diff --git a/web-interface/src/components/widgets/elements/TabPageElement.vue b/web-interface/src/components/widgets/elements/TabPageElement.vue new file mode 100644 index 0000000..5328135 --- /dev/null +++ b/web-interface/src/components/widgets/elements/TabPageElement.vue @@ -0,0 +1,61 @@ + + + diff --git a/web-interface/src/components/widgets/elements/TabViewElement.vue b/web-interface/src/components/widgets/elements/TabViewElement.vue new file mode 100644 index 0000000..3284a56 --- /dev/null +++ b/web-interface/src/components/widgets/elements/TabViewElement.vue @@ -0,0 +1,126 @@ + + + diff --git a/web-interface/src/components/widgets/settings/ButtonSettings.vue b/web-interface/src/components/widgets/settings/ButtonSettings.vue new file mode 100644 index 0000000..b5547f1 --- /dev/null +++ b/web-interface/src/components/widgets/settings/ButtonSettings.vue @@ -0,0 +1,81 @@ + + + diff --git a/web-interface/src/components/widgets/settings/ChartSettings.vue b/web-interface/src/components/widgets/settings/ChartSettings.vue new file mode 100644 index 0000000..10549c5 --- /dev/null +++ b/web-interface/src/components/widgets/settings/ChartSettings.vue @@ -0,0 +1,108 @@ + + + diff --git a/web-interface/src/components/widgets/settings/ClockSettings.vue b/web-interface/src/components/widgets/settings/ClockSettings.vue new file mode 100644 index 0000000..3548fe2 --- /dev/null +++ b/web-interface/src/components/widgets/settings/ClockSettings.vue @@ -0,0 +1,26 @@ + + + diff --git a/web-interface/src/components/widgets/settings/IconSettings.vue b/web-interface/src/components/widgets/settings/IconSettings.vue new file mode 100644 index 0000000..932a373 --- /dev/null +++ b/web-interface/src/components/widgets/settings/IconSettings.vue @@ -0,0 +1,66 @@ + + + diff --git a/web-interface/src/components/widgets/settings/LabelSettings.vue b/web-interface/src/components/widgets/settings/LabelSettings.vue new file mode 100644 index 0000000..1d4260a --- /dev/null +++ b/web-interface/src/components/widgets/settings/LabelSettings.vue @@ -0,0 +1,113 @@ + + + diff --git a/web-interface/src/components/widgets/settings/LedSettings.vue b/web-interface/src/components/widgets/settings/LedSettings.vue new file mode 100644 index 0000000..f88eef4 --- /dev/null +++ b/web-interface/src/components/widgets/settings/LedSettings.vue @@ -0,0 +1,55 @@ + + + diff --git a/web-interface/src/components/widgets/settings/PowerFlowSettings.vue b/web-interface/src/components/widgets/settings/PowerFlowSettings.vue new file mode 100644 index 0000000..9f5978f --- /dev/null +++ b/web-interface/src/components/widgets/settings/PowerFlowSettings.vue @@ -0,0 +1,175 @@ + + + diff --git a/web-interface/src/components/widgets/settings/PowerNodeSettings.vue b/web-interface/src/components/widgets/settings/PowerNodeSettings.vue new file mode 100644 index 0000000..4b677cb --- /dev/null +++ b/web-interface/src/components/widgets/settings/PowerNodeSettings.vue @@ -0,0 +1,258 @@ + + + diff --git a/web-interface/src/components/widgets/settings/RoomCardSettings.vue b/web-interface/src/components/widgets/settings/RoomCardSettings.vue new file mode 100644 index 0000000..81abc56 --- /dev/null +++ b/web-interface/src/components/widgets/settings/RoomCardSettings.vue @@ -0,0 +1,339 @@ + + + diff --git a/web-interface/src/components/widgets/settings/TabPageSettings.vue b/web-interface/src/components/widgets/settings/TabPageSettings.vue new file mode 100644 index 0000000..9d2a893 --- /dev/null +++ b/web-interface/src/components/widgets/settings/TabPageSettings.vue @@ -0,0 +1,30 @@ + + + diff --git a/web-interface/src/components/widgets/settings/TabViewSettings.vue b/web-interface/src/components/widgets/settings/TabViewSettings.vue new file mode 100644 index 0000000..4e96eb3 --- /dev/null +++ b/web-interface/src/components/widgets/settings/TabViewSettings.vue @@ -0,0 +1,41 @@ + + + diff --git a/web-interface/src/components/widgets/shared/BaseWidget.vue b/web-interface/src/components/widgets/shared/BaseWidget.vue new file mode 100644 index 0000000..cfc9a0b --- /dev/null +++ b/web-interface/src/components/widgets/shared/BaseWidget.vue @@ -0,0 +1,67 @@ + + + diff --git a/web-interface/src/components/widgets/shared/styles.js b/web-interface/src/components/widgets/shared/styles.js new file mode 100644 index 0000000..98e646e --- /dev/null +++ b/web-interface/src/components/widgets/shared/styles.js @@ -0,0 +1,11 @@ +// Shared CSS classes for settings components +export const rowClass = 'flex items-center gap-2.5 mb-2'; +export const labelClass = 'w-[90px] text-[12px] text-muted'; +export const inputClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent'; +export const headingClass = 'mt-4 mb-2.5 text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]'; +export const subHeadingClass = 'mt-3 mb-2 text-[11px] uppercase tracking-[0.06em] text-[#5a7f9a]'; +export const noteClass = 'text-[11px] text-muted leading-tight'; +export const iconSelectClass = 'flex-1 bg-panel-2 border border-border rounded-lg px-2.5 py-1.5 text-text text-[12px] flex items-center justify-center gap-1.5 cursor-pointer hover:bg-[#e4ebf2] hover:border-[#b8c4d2]'; +export const iconRemoveClass = 'w-7 h-7 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[12px] cursor-pointer hover:bg-[#f2cfcf]'; +export const colorInputClass = 'h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0'; +export const smallColorInputClass = 'h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0'; diff --git a/web-interface/src/components/widgets/shared/utils.js b/web-interface/src/components/widgets/shared/utils.js new file mode 100644 index 0000000..a088dc5 --- /dev/null +++ b/web-interface/src/components/widgets/shared/utils.js @@ -0,0 +1,58 @@ +// Shared utility functions for widget rendering +import { fontSizes } from '../../../constants'; +import { clamp, hexToRgba } from '../../../utils'; + +export { clamp, hexToRgba }; + +// Text alignment helpers +export function justifyForAlign(align) { + if (align === 0) return 'flex-start'; + if (align === 2) return 'flex-end'; + return 'center'; +} + +export function textAlignCss(align) { + if (align === 0) return 'left'; + if (align === 2) return 'right'; + return 'center'; +} + +// Base style for all widgets +export function getBaseStyle(widget, scale) { + return { + left: `${widget.x * scale}px`, + top: `${widget.y * scale}px`, + width: `${widget.w * scale}px`, + height: `${widget.h * scale}px`, + fontSize: `${(fontSizes[widget.fontSize] || 14) * scale}px`, + color: widget.textColor, + position: 'absolute', + zIndex: 1, + cursor: 'move', + userSelect: 'none', + touchAction: 'none' + }; +} + +// Shadow style helper +export function getShadowStyle(widget, scale) { + if (!widget.shadow || !widget.shadow.enabled) return {}; + + const sx = (widget.shadow.x || 0) * scale; + const sy = (widget.shadow.y || 0) * scale; + const blur = (widget.shadow.blur || 0) * scale; + const spread = (widget.shadow.spread || 0) * scale; + + return { + boxShadow: `${sx}px ${sy}px ${blur}px ${spread}px ${widget.shadow.color}` + }; +} + +// PowerNode text splitting +export function splitPowerNodeText(text) { + if (typeof text !== 'string') return { label: '', value: '' }; + const parts = text.split('\n'); + const label = parts[0] ?? ''; + const value = parts.slice(1).join('\n'); + return { label, value }; +} diff --git a/web-interface/src/constants.js b/web-interface/src/constants.js index 6f33832..536ca3f 100644 --- a/web-interface/src/constants.js +++ b/web-interface/src/constants.js @@ -155,6 +155,10 @@ export const ICON_DEFAULTS = { export const fontSizes = [14, 18, 22, 28, 36, 48]; +// Icon fonts have additional larger sizes for decorative use +// Large icons use PSRAM for draw buffers (ESP32-P4 with 32MB PSRAM) +export const iconFontSizes = [14, 18, 22, 28, 36, 48, 64, 80, 96, 120, 150, 180, 220, 260]; + export const defaultFormats = { 1: '%.1f °C', 2: '%s', @@ -441,7 +445,7 @@ export const WIDGET_DEFAULTS = { textColor: '#223447', bgColor: '#FFFFFF', bgOpacity: 255, - radius: 100, + radius: 16, // For Tile style (Bubble ignores this) shadow: { enabled: true, x: 0, y: 4, blur: 12, spread: 0, color: '#00000022' }, isToggle: false, knxAddrWrite: 0, @@ -456,6 +460,7 @@ export const WIDGET_DEFAULTS = { subButtonDistance: 80, // Distance from center in pixels subButtonOpacity: 255, // Sub-button opacity (0-255) cardStyle: 0, // 0=Bubble (round), 1=Tile (rectangular) - subButtons: [] + subButtons: [], + textLines: [] // Variable text lines with icon, text, textSrc, knxAddr, fontSize } }; diff --git a/web-interface/src/stores/editor.js b/web-interface/src/stores/editor.js index 961df96..5ad5a67 100644 --- a/web-interface/src/stores/editor.js +++ b/web-interface/src/stores/editor.js @@ -168,7 +168,9 @@ export const useEditorStore = defineStore('editor', () => { iconCodepoint: w.iconCodepoint || 0, iconPosition: w.iconPosition ?? defaults.iconPosition, iconSize: w.iconSize ?? defaults.iconSize, - iconGap: w.iconGap ?? defaults.iconGap + iconGap: w.iconGap ?? defaults.iconGap, + iconPositionX: w.iconPositionX ?? defaults.iconPositionX, + iconPositionY: w.iconPositionY ?? defaults.iconPositionY, }; screen.widgets.push(label); @@ -408,7 +410,9 @@ export const useEditorStore = defineStore('editor', () => { iconCodepoint: defaults.iconCodepoint || 0, iconPosition: defaults.iconPosition || 0, iconSize: defaults.iconSize || 1, - iconGap: defaults.iconGap || 8 + iconGap: defaults.iconGap || 8, + iconPositionX: defaults.iconPositionX || 8, + iconPositionY: defaults.iconPositionY || 8 }; if (defaults.chart) { @@ -429,9 +433,10 @@ export const useEditorStore = defineStore('editor', () => { w.conditions = []; } - // RoomCard: initialize sub-buttons array + // RoomCard: initialize sub-buttons and text lines arrays if (typeStr === 'roomcard') { w.subButtons = []; + w.textLines = []; } activeScreen.value.widgets.push(w); @@ -465,7 +470,9 @@ export const useEditorStore = defineStore('editor', () => { iconCodepoint: w.iconCodepoint || 0, iconPosition: w.iconPosition || 0, iconSize: w.iconSize || 1, - iconGap: w.iconGap || 8 + iconGap: w.iconGap || 8, + iconPositionX: w.iconPositionX || 8, + iconPositionY: w.iconPositionY || 8 }; activeScreen.value.widgets.push(label); } @@ -526,7 +533,9 @@ export const useEditorStore = defineStore('editor', () => { iconCodepoint: defaults.iconCodepoint || 0, iconPosition: defaults.iconPosition || 0, iconSize: defaults.iconSize || 0, - iconGap: defaults.iconGap || 0 + iconGap: defaults.iconGap || 0, + iconPositionX: defaults.iconPositionX || 0, + iconPositionY: defaults.iconPositionY || 0, }; activeScreen.value.widgets.push(link); diff --git a/web-interface/src/utils.js b/web-interface/src/utils.js index 3b69e46..d7fc8ac 100644 --- a/web-interface/src/utils.js +++ b/web-interface/src/utils.js @@ -71,6 +71,19 @@ export function normalizeWidget(w, nextWidgetIdRef) { } } + // TextLines: ensure each widget has its own array and each line has fontSize + if (defaults.textLines !== undefined) { + if (!Array.isArray(w.textLines)) { + w.textLines = []; + } + // Ensure each textLine has a fontSize + w.textLines.forEach(tl => { + if (tl.fontSize === undefined || tl.fontSize === null) { + tl.fontSize = 1; // Default 18px + } + }); + } + if (!w.shadow) { w.shadow = { ...defaults.shadow }; } else { @@ -126,6 +139,8 @@ export function normalizeWidget(w, nextWidgetIdRef) { if (w.iconPosition === undefined) w.iconPosition = defaults.iconPosition || 0; if (w.iconSize === undefined) w.iconSize = defaults.iconSize || 1; if (w.iconGap === undefined) w.iconGap = defaults.iconGap || 8; + if (w.iconPositionX === undefined) w.iconPositionX = defaults.iconPositionX || 0; + if (w.iconPositionY === undefined) w.iconPositionY = defaults.iconPositionY || 0; // Hierarchy if (w.parentId === undefined) w.parentId = -1;