This commit is contained in:
Thomas Peterson 2026-02-02 17:16:23 +01:00
parent ae8bb5a01f
commit 6a8b74a652
16 changed files with 500 additions and 56 deletions

View File

@ -108,6 +108,8 @@ void WidgetConfig::serialize(uint8_t* buf) const {
buf[pos++] = subButtonCount; buf[pos++] = subButtonCount;
buf[pos++] = subButtonSize; buf[pos++] = subButtonSize;
buf[pos++] = subButtonDistance; buf[pos++] = subButtonDistance;
buf[pos++] = subButtonOpacity;
buf[pos++] = cardStyle;
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
const SubButtonConfig& sb = subButtons[i]; const SubButtonConfig& sb = subButtons[i];
buf[pos++] = sb.iconCodepoint & 0xFF; buf[pos++] = sb.iconCodepoint & 0xFF;
@ -253,17 +255,22 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
} }
// RoomCard sub-buttons // RoomCard sub-buttons
if (pos + 3 <= SERIALIZED_SIZE) { if (pos + 5 <= SERIALIZED_SIZE) {
subButtonCount = buf[pos++]; subButtonCount = buf[pos++];
if (subButtonCount > MAX_SUBBUTTONS) subButtonCount = MAX_SUBBUTTONS; if (subButtonCount > MAX_SUBBUTTONS) subButtonCount = MAX_SUBBUTTONS;
subButtonSize = buf[pos++]; subButtonSize = buf[pos++];
if (subButtonSize == 0) subButtonSize = 40; // Default if (subButtonSize == 0) subButtonSize = 40; // Default
subButtonDistance = buf[pos++]; subButtonDistance = buf[pos++];
if (subButtonDistance == 0) subButtonDistance = 80; // Default 80px if (subButtonDistance == 0) subButtonDistance = 80; // Default 80px
subButtonOpacity = buf[pos++];
if (subButtonOpacity == 0) subButtonOpacity = 255; // Default fully opaque
cardStyle = buf[pos++]; // 0=Bubble, 1=Tile
} else { } else {
subButtonCount = 0; subButtonCount = 0;
subButtonSize = 40; subButtonSize = 40;
subButtonDistance = 80; subButtonDistance = 80;
subButtonOpacity = 255;
cardStyle = 0; // Default to Bubble style
} }
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {

View File

@ -256,11 +256,13 @@ struct WidgetConfig {
uint8_t subButtonCount; uint8_t subButtonCount;
uint8_t subButtonSize; // Sub-button size in pixels (default 40) uint8_t subButtonSize; // Sub-button size in pixels (default 40)
uint8_t subButtonDistance; // Distance from center in pixels (default 80) uint8_t subButtonDistance; // Distance from center in pixels (default 80)
uint8_t subButtonOpacity; // Sub-button opacity 0-255 (default 255)
uint8_t cardStyle; // 0=Bubble (round), 1=Tile (rectangular)
SubButtonConfig subButtons[MAX_SUBBUTTONS]; SubButtonConfig subButtons[MAX_SUBBUTTONS];
// Serialization size (fixed for NVS storage) // Serialization size (fixed for NVS storage)
// 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 120 (6 subButtons * 20) = 320 // 197 + 1 (subButtonCount) + 1 (subButtonSize) + 1 (subButtonDistance) + 1 (subButtonOpacity) + 1 (cardStyle) + 120 (6 subButtons * 20) = 322
static constexpr size_t SERIALIZED_SIZE = 320; static constexpr size_t SERIALIZED_SIZE = 322;
void serialize(uint8_t* buf) const; void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf); void deserialize(const uint8_t* buf);

View File

@ -1608,6 +1608,8 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
if (w.type == WidgetType::ROOMCARD) { if (w.type == WidgetType::ROOMCARD) {
cJSON_AddNumberToObject(widget, "subButtonSize", w.subButtonSize); cJSON_AddNumberToObject(widget, "subButtonSize", w.subButtonSize);
cJSON_AddNumberToObject(widget, "subButtonDistance", w.subButtonDistance); cJSON_AddNumberToObject(widget, "subButtonDistance", w.subButtonDistance);
cJSON_AddNumberToObject(widget, "subButtonOpacity", w.subButtonOpacity);
cJSON_AddNumberToObject(widget, "cardStyle", w.cardStyle);
} }
if (w.type == WidgetType::ROOMCARD && w.subButtonCount > 0) { if (w.type == WidgetType::ROOMCARD && w.subButtonCount > 0) {
cJSON* subButtons = cJSON_AddArrayToObject(widget, "subButtons"); cJSON* subButtons = cJSON_AddArrayToObject(widget, "subButtons");
@ -1956,6 +1958,18 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
} else { } else {
w.subButtonDistance = 80; // Default 80px w.subButtonDistance = 80; // Default 80px
} }
cJSON* subButtonOpacity = cJSON_GetObjectItem(widget, "subButtonOpacity");
if (cJSON_IsNumber(subButtonOpacity)) {
w.subButtonOpacity = subButtonOpacity->valueint;
} else {
w.subButtonOpacity = 255; // Default fully opaque
}
cJSON* cardStyle = cJSON_GetObjectItem(widget, "cardStyle");
if (cJSON_IsNumber(cardStyle)) {
w.cardStyle = cardStyle->valueint;
} else {
w.cardStyle = 0; // Default to Bubble style
}
// RoomCard sub-buttons // RoomCard sub-buttons
cJSON* subButtons = cJSON_GetObjectItem(widget, "subButtons"); cJSON* subButtons = cJSON_GetObjectItem(widget, "subButtons");

View File

@ -14,11 +14,39 @@ RoomCardWidget::RoomCardWidget(const WidgetConfig& config)
{ {
roomName_[0] = '\0'; roomName_[0] = '\0';
tempFormat_[0] = '\0'; tempFormat_[0] = '\0';
subtitle_[0] = '\0';
humidityFormat_[0] = '\0';
for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) { for (size_t i = 0; i < MAX_SUBBUTTONS; ++i) {
subButtonStates_[i] = false; 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) { int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) {
if (codepoint < 0x80) { if (codepoint < 0x80) {
buf[0] = static_cast<char>(codepoint); buf[0] = static_cast<char>(codepoint);
@ -50,7 +78,11 @@ int RoomCardWidget::encodeUtf8(uint32_t codepoint, char* buf) {
void RoomCardWidget::parseText() { void RoomCardWidget::parseText() {
roomName_[0] = '\0'; roomName_[0] = '\0';
tempFormat_[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; const char* text = config_.text;
if (text && text[0] != '\0') { if (text && text[0] != '\0') {
const char* newline = strchr(text, '\n'); const char* newline = strchr(text, '\n');
@ -59,13 +91,33 @@ void RoomCardWidget::parseText() {
if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1; if (nameLen >= MAX_TEXT_LEN) nameLen = MAX_TEXT_LEN - 1;
memcpy(roomName_, text, nameLen); memcpy(roomName_, text, nameLen);
roomName_[nameLen] = '\0'; roomName_[nameLen] = '\0';
strncpy(tempFormat_, newline + 1, MAX_TEXT_LEN - 1);
tempFormat_[MAX_TEXT_LEN - 1] = '\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 { } else {
strncpy(roomName_, text, MAX_TEXT_LEN - 1); strncpy(roomName_, text, MAX_TEXT_LEN - 1);
roomName_[MAX_TEXT_LEN - 1] = '\0'; 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) { void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y) {
@ -86,26 +138,46 @@ void RoomCardWidget::calculateSubButtonPosition(SubButtonPosition pos, int16_t&
y = centerY + static_cast<int16_t>(orbitRadius * sinf(angle)) - subBtnSize / 2; y = centerY + static_cast<int16_t>(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) { lv_obj_t* RoomCardWidget::create(lv_obj_t* parent) {
parseText(); parseText();
// Store parent for sub-buttons (they're created on screen level to avoid clipping)
screenParent_ = parent;
// Create main container // Create main container
obj_ = lv_obj_create(parent); obj_ = lv_obj_create(parent);
lv_obj_remove_style_all(obj_); lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y); lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_, config_.width, config_.height); lv_obj_set_size(obj_, config_.width, config_.height);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
// Create central bubble first // Add click handler for navigation
createCentralBubble(); lv_obj_add_event_cb(obj_, bubbleClickCallback, LV_EVENT_CLICKED, this);
// Create sub-buttons // Create layout based on card style
createSubButtons(); if (config_.cardStyle == 1) {
createTileLayout();
createSubButtonsTile();
} else {
createBubbleLayout();
createSubButtons();
}
return obj_; return obj_;
} }
void RoomCardWidget::createCentralBubble() { void RoomCardWidget::createBubbleLayout() {
// Calculate bubble size (80% of widget size, circular) // Calculate bubble size (80% of widget size, circular)
int16_t minSide = config_.width < config_.height ? config_.width : config_.height; int16_t minSide = config_.width < config_.height ? config_.width : config_.height;
int16_t bubbleSize = (minSide * 80) / 100; int16_t bubbleSize = (minSide * 80) / 100;
@ -154,6 +226,94 @@ void RoomCardWidget::createCentralBubble() {
lv_obj_add_event_cb(bubble_, bubbleClickCallback, LV_EVENT_CLICKED, this); 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() { void RoomCardWidget::createSubButtons() {
// Sub-button size (configurable, default 40) // Sub-button size (configurable, default 40)
int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40; int16_t subBtnSize = config_.subButtonSize > 0 ? config_.subButtonSize : 40;
@ -162,8 +322,8 @@ void RoomCardWidget::createSubButtons() {
const SubButtonConfig& cfg = config_.subButtons[i]; const SubButtonConfig& cfg = config_.subButtons[i];
if (!cfg.enabled) continue; if (!cfg.enabled) continue;
// Create sub-button object // Create sub-button on screen parent (not on widget container) to avoid clipping
lv_obj_t* btn = lv_obj_create(obj_); lv_obj_t* btn = lv_obj_create(screenParent_);
lv_obj_remove_style_all(btn); lv_obj_remove_style_all(btn);
lv_obj_set_size(btn, subBtnSize, subBtnSize); lv_obj_set_size(btn, subBtnSize, subBtnSize);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
@ -171,12 +331,64 @@ void RoomCardWidget::createSubButtons() {
// Circular shape // Circular shape
lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(btn, 255, 0); lv_obj_set_style_bg_opa(btn, config_.subButtonOpacity, 0);
// Position using circle geometry // Position using circle geometry (relative to widget center)
int16_t x, y; int16_t relX, relY;
calculateSubButtonPosition(cfg.position, x, y); calculateSubButtonPosition(cfg.position, relX, relY);
lv_obj_set_pos(btn, x, y); // 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<void*>(static_cast<uintptr_t>(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 // Create icon
if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) { if (cfg.iconCodepoint > 0 && Fonts::hasIconFont()) {
@ -204,42 +416,109 @@ void RoomCardWidget::createSubButtons() {
void RoomCardWidget::applyStyle() { void RoomCardWidget::applyStyle() {
if (!obj_) return; if (!obj_) return;
// 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);
lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor()); lv_color_t textColor = lv_color_hex(config_.textColor.toLvColor());
// Style room icon if (config_.cardStyle == 1) {
if (roomIcon_ && iconFont) { // Tile style
lv_obj_set_style_text_font(roomIcon_, iconFont, 0); // Apply border if shadow is enabled (used as accent border)
lv_obj_set_style_text_color(roomIcon_, textColor, 0); 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);
}
// Style temperature // Room name - large font
if (tempLabel_ && textFont) { if (roomLabel_) {
lv_obj_set_style_text_font(tempLabel_, textFont, 0); const lv_font_t* nameFont = Fonts::bySizeIndex(config_.fontSize);
lv_obj_set_style_text_color(tempLabel_, textColor, 0); lv_obj_set_style_text_font(roomLabel_, nameFont, 0);
} lv_obj_set_style_text_color(roomLabel_, textColor, 0);
}
// Style room label // Subtitle - smaller, dimmed
if (roomLabel_ && labelFont) { if (subtitleLabel_) {
lv_obj_set_style_text_font(roomLabel_, labelFont, 0); const lv_font_t* subFont = Fonts::bySizeIndex(config_.fontSize > 1 ? config_.fontSize - 2 : 0);
lv_obj_set_style_text_color(roomLabel_, lv_color_hex(config_.textColor.toLvColor()), 0); lv_obj_set_style_text_font(subtitleLabel_, subFont, 0);
lv_obj_set_style_text_opa(roomLabel_, 180, 0); // Slightly dimmed 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 // Style sub-buttons - adjust icon size based on button size
// subButtonSize: 30-40 -> font 1, 41-55 -> font 2, 56+ -> font 3
uint8_t subBtnFontIdx = 1; // Default small uint8_t subBtnFontIdx = 1; // Default small
if (config_.subButtonSize > 55) { if (config_.subButtonSize > 55) {
subBtnFontIdx = 3; subBtnFontIdx = 3;
@ -272,6 +551,14 @@ void RoomCardWidget::updateTemperature(float value) {
lv_label_set_text(tempLabel_, buf); 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) { void RoomCardWidget::onKnxValue(float value) {
cachedPrimaryValue_ = value; cachedPrimaryValue_ = value;
hasCachedPrimary_ = true; hasCachedPrimary_ = true;

View File

@ -5,6 +5,7 @@
class RoomCardWidget : public Widget { class RoomCardWidget : public Widget {
public: public:
explicit RoomCardWidget(const WidgetConfig& config); explicit RoomCardWidget(const WidgetConfig& config);
~RoomCardWidget() override;
lv_obj_t* create(lv_obj_t* parent) override; lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override; void applyStyle() override;
void onKnxValue(float value) override; // Temperature update void onKnxValue(float value) override; // Temperature update
@ -14,13 +15,24 @@ public:
void onSubButtonStatus(uint8_t index, bool value); void onSubButtonStatus(uint8_t index, bool value);
void sendSubButtonToggle(uint8_t index); void sendSubButtonToggle(uint8_t index);
// Override to also clear sub-buttons
void clearLvglObject();
private: private:
// Central bubble elements // 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* bubble_ = nullptr;
lv_obj_t* roomIcon_ = nullptr; lv_obj_t* roomIcon_ = nullptr;
lv_obj_t* roomLabel_ = nullptr; lv_obj_t* roomLabel_ = nullptr;
lv_obj_t* tempLabel_ = 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 // Sub-button elements
lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {}; lv_obj_t* subButtonObjs_[MAX_SUBBUTTONS] = {};
lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {}; lv_obj_t* subButtonIcons_[MAX_SUBBUTTONS] = {};
@ -29,16 +41,22 @@ private:
// Cached config values // Cached config values
char roomName_[MAX_TEXT_LEN] = {0}; char roomName_[MAX_TEXT_LEN] = {0};
char tempFormat_[MAX_TEXT_LEN] = {0}; char tempFormat_[MAX_TEXT_LEN] = {0};
char subtitle_[MAX_TEXT_LEN] = {0};
char humidityFormat_[MAX_FORMAT_LEN] = {0};
// Layout helpers // Layout helpers
void parseText(); void parseText();
void createCentralBubble(); void createBubbleLayout(); // Bubble style (round)
void createTileLayout(); // Tile style (rectangular)
void createSubButtons(); void createSubButtons();
void createSubButtonsTile(); // SubButtons for tile (right side)
void updateSubButtonColor(uint8_t index); void updateSubButtonColor(uint8_t index);
void updateTemperature(float value); void updateTemperature(float value);
void updateHumidity(float value);
// Geometry calculations // Geometry calculations
void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y); void calculateSubButtonPosition(SubButtonPosition pos, int16_t& x, int16_t& y);
void calculateSubButtonPositionTile(uint8_t index, int16_t& x, int16_t& y);
// Event handlers // Event handlers
static void bubbleClickCallback(lv_event_t* e); static void bubbleClickCallback(lv_event_t* e);

View File

@ -395,6 +395,16 @@
</div> </div>
</template> </template>
<!-- Card Style -->
<h4 :class="headingClass">Karten-Stil</h4>
<div :class="rowClass">
<label :class="labelClass">Layout</label>
<select :class="inputClass" v-model.number="w.cardStyle">
<option :value="0">Bubble (rund)</option>
<option :value="1">Tile (rechteckig)</option>
</select>
</div>
<!-- Sub-Buttons --> <!-- Sub-Buttons -->
<h4 :class="headingClass">Sub-Buttons</h4> <h4 :class="headingClass">Sub-Buttons</h4>
<div :class="rowClass"> <div :class="rowClass">
@ -419,6 +429,10 @@
<input :class="inputClass" type="number" min="40" max="200" v-model.number="w.subButtonDistance"> <input :class="inputClass" type="number" min="40" max="200" v-model.number="w.subButtonDistance">
<span class="text-[10px] text-muted">px</span> <span class="text-[10px] text-muted">px</span>
</div> </div>
<div v-if="subButtonCount > 0" :class="rowClass">
<label :class="labelClass">Btn Opacity</label>
<input :class="inputClass" type="number" min="0" max="255" v-model.number="w.subButtonOpacity">
</div>
<div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2"> <div v-for="(sb, idx) in subButtons" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted"> <div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Btn {{ idx + 1 }}</label> <label class="w-[50px]">Btn {{ idx + 1 }}</label>
@ -585,7 +599,7 @@
<div :class="rowClass"><label :class="labelClass">Ringfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div> <div :class="rowClass"><label :class="labelClass">Ringfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Ring Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div> <div :class="rowClass"><label :class="labelClass">Ring Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
</template> </template>
<template v-else> <template v-else-if="key !== 'roomcard'">
<h4 :class="headingClass">Stil</h4> <h4 :class="headingClass">Stil</h4>
<div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div> <div :class="rowClass"><label :class="labelClass">Textfarbe</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div> <div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>

View File

@ -138,8 +138,8 @@
</div> </div>
</template> </template>
<!-- RoomCard Widget --> <!-- RoomCard Widget - Bubble Style -->
<template v-else-if="isRoomCard"> <template v-else-if="isRoomCard && widget.cardStyle !== 1">
<!-- Central Bubble --> <!-- Central Bubble -->
<div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle"> <div class="absolute rounded-full flex flex-col items-center justify-center" :style="roomCardBubbleStyle">
<span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle"> <span v-if="widget.iconCodepoint" class="material-symbols-outlined" :style="roomCardIconStyle">
@ -148,7 +148,7 @@
<span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span> <span v-if="roomCardParts.name" class="leading-tight font-semibold" :style="roomCardNameStyle">{{ roomCardParts.name }}</span>
<span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span> <span v-if="roomCardParts.format" class="leading-tight opacity-70" :style="roomCardTempStyle">{{ roomCardParts.format }}</span>
</div> </div>
<!-- Sub-Buttons --> <!-- Sub-Buttons (circular orbit) -->
<div <div
v-for="(sb, idx) in roomCardSubButtons" v-for="(sb, idx) in roomCardSubButtons"
:key="idx" :key="idx"
@ -161,6 +161,46 @@
</div> </div>
</template> </template>
<!-- RoomCard Widget - Tile Style -->
<template v-else-if="isRoomCard && widget.cardStyle === 1">
<div class="absolute inset-0 overflow-hidden" :style="roomCardTileContainerStyle">
<!-- Room name (top-left) -->
<div class="absolute" :style="{ left: '16px', top: '12px', color: widget.textColor, fontSize: fontSizes[widget.fontSize || 2] + 'px', fontWeight: 600 }">
{{ roomCardParts.name }}
</div>
<!-- Subtitle -->
<div v-if="roomCardParts.subtitle" class="absolute opacity-60" :style="{ left: '16px', top: '38px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 2)] + 'px' }">
{{ roomCardParts.subtitle }}
</div>
<!-- Temperature + Humidity row -->
<div class="absolute flex items-center gap-4" :style="{ left: '16px', top: '64px', color: widget.textColor, fontSize: fontSizes[Math.max(0, (widget.fontSize || 2) - 1)] + 'px' }">
<span v-if="widget.text2" class="flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">device_thermostat</span>
<span>{{ widget.text2 || '--' }}</span>
</span>
<span v-if="widget.text3" class="flex items-center gap-1">
<span class="material-symbols-outlined text-[14px]">humidity_percentage</span>
<span>{{ widget.text3 || '--' }}</span>
</span>
</div>
<!-- Large decorative icon (bottom-left) -->
<span v-if="widget.iconCodepoint" class="material-symbols-outlined absolute opacity-20" :style="{ left: '-20px', bottom: '-20px', fontSize: '120px', color: widget.textColor }">
{{ iconChar }}
</span>
</div>
<!-- Sub-Buttons (right side, vertical) -->
<div
v-for="(sb, idx) in roomCardSubButtons"
:key="idx"
class="absolute rounded-full flex items-center justify-center shadow-md"
:style="getSubButtonStyleTile(sb, idx)"
>
<span v-if="sb.icon" class="material-symbols-outlined" :style="getSubButtonIconStyle(sb)">
{{ String.fromCodePoint(sb.icon) }}
</span>
</div>
</template>
<!-- Icon-only Widget --> <!-- Icon-only Widget -->
<template v-else-if="isIcon"> <template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle"> <span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
@ -481,16 +521,57 @@ const powerFlowBgStyle = computed(() => {
// RoomCard computed properties // RoomCard computed properties
const roomCardParts = computed(() => { const roomCardParts = computed(() => {
if (!props.widget.text) return { name: '', format: '' }; if (!props.widget.text) return { name: '', format: '', subtitle: '' };
const parts = props.widget.text.split('\n'); const parts = props.widget.text.split('\n');
// For Tile style: second line is subtitle, temp/humidity come from text2/text3
// For Bubble style: second line is temp format
if (props.widget.cardStyle === 1) {
return {
name: parts[0] || '',
subtitle: parts[1] || '',
format: ''
};
}
return { return {
name: parts[0] || '', name: parts[0] || '',
format: parts[1] || '' format: parts[1] || '',
subtitle: ''
}; };
}); });
const roomCardSubButtons = computed(() => props.widget.subButtons || []); const roomCardSubButtons = computed(() => props.widget.subButtons || []);
// Tile style container
const roomCardTileContainerStyle = computed(() => {
const alpha = (props.widget.bgOpacity ?? 255) / 255;
const style = {
backgroundColor: hexToRgba(props.widget.bgColor || '#333333', alpha),
borderRadius: (props.widget.radius || 16) + 'px',
};
// Border from shadow settings
if (props.widget.shadow?.enabled) {
style.border = `3px solid ${props.widget.shadow.color || '#ff6b6b'}`;
}
return style;
});
// Sub-button positioning for Tile (right side, vertical)
const getSubButtonStyleTile = (sb, idx) => {
const s = props.scale;
const btnSize = (props.widget.subButtonSize || 40) * s;
const gap = 10 * s;
const padding = 12 * s;
const w = props.widget.w * s;
return {
width: btnSize + 'px',
height: btnSize + 'px',
right: padding + 'px',
top: (padding + idx * (btnSize + gap)) + 'px',
backgroundColor: sb.colorOff || '#666666',
};
};
const roomCardBubbleStyle = computed(() => { const roomCardBubbleStyle = computed(() => {
const s = props.scale; const s = props.scale;
const w = props.widget.w; const w = props.widget.w;
@ -723,8 +804,19 @@ const computedStyle = computed(() => {
style.left = '0'; style.left = '0';
style.top = '0'; style.top = '0';
} else if (isRoomCard.value) { } else if (isRoomCard.value) {
// RoomCard container - transparent, children handle rendering if (w.cardStyle === 1) {
style.overflow = 'visible'; // Tile style - rectangular with rounded corners
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1);
style.background = hexToRgba(w.bgColor || '#333333', alpha);
style.borderRadius = `${(w.radius || 16) * s}px`;
style.overflow = 'hidden';
if (w.shadow && w.shadow.enabled) {
style.border = `3px solid ${w.shadow.color || '#ff6b6b'}`;
}
} else {
// Bubble style - container transparent, children handle rendering
style.overflow = 'visible';
}
} }
return style; return style;

View File

@ -454,6 +454,8 @@ export const WIDGET_DEFAULTS = {
iconGap: 8, iconGap: 8,
subButtonSize: 40, // Sub-button size in pixels subButtonSize: 40, // Sub-button size in pixels
subButtonDistance: 80, // Distance from center in pixels subButtonDistance: 80, // Distance from center in pixels
subButtonOpacity: 255, // Sub-button opacity (0-255)
cardStyle: 0, // 0=Bubble (round), 1=Tile (rectangular)
subButtons: [] subButtons: []
} }
}; };

View File

@ -61,6 +61,14 @@ export function normalizeWidget(w, nextWidgetIdRef) {
if (w.subButtonDistance === undefined || w.subButtonDistance === null) { if (w.subButtonDistance === undefined || w.subButtonDistance === null) {
w.subButtonDistance = defaults.subButtonDistance || 80; w.subButtonDistance = defaults.subButtonDistance || 80;
} }
// Ensure subButtonOpacity has a default
if (w.subButtonOpacity === undefined || w.subButtonOpacity === null) {
w.subButtonOpacity = defaults.subButtonOpacity || 255;
}
// Ensure cardStyle has a default
if (w.cardStyle === undefined || w.cardStyle === null) {
w.cardStyle = defaults.cardStyle || 0;
}
} }
if (!w.shadow) { if (!w.shadow) {