Compare commits
3 Commits
b6c4477c46
...
06e81301b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e81301b2 | ||
|
|
237a3a96c5 | ||
|
|
2a5088bec3 |
@@ -51,6 +51,16 @@ dependencies:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.2
|
||||
joltwallet/littlefs:
|
||||
component_hash: 1808d73e99168f6f3c26dd31799a248484762b3a320ec4962dec11a145f4277f
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.20.3
|
||||
lvgl/lvgl:
|
||||
component_hash: 17e68bfd21f0edf4c3ee838e2273da840bf3930e5dbc3bfa6c1190c3aed41f9f
|
||||
dependencies: []
|
||||
@@ -63,7 +73,8 @@ direct_dependencies:
|
||||
- espressif/esp_lcd_touch_gt911
|
||||
- espressif/esp_lvgl_port
|
||||
- idf
|
||||
- joltwallet/littlefs
|
||||
- lvgl/lvgl
|
||||
manifest_hash: 2010806782b4d2486b02b853afa44a545717d3d0593eb60f9aa6e5c696270f8f
|
||||
manifest_hash: 534b6804ed0fcb2390bfe237db938fe86c9ba00561b361035a89dde4847214f2
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
552
main/io/fs_handler.cpp
Normal file
552
main/io/fs_handler.cpp
Normal file
@@ -0,0 +1,552 @@
|
||||
#include "io/fs_handler.h"
|
||||
#include <sys/stat.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <cstring>
|
||||
#include "esp_partition.h"
|
||||
|
||||
#define TAG "LittleFSHandler"
|
||||
#define PARTITION_LABEL "storage"
|
||||
#define BLOCK_SIZE 512 // Match typical flash sector size
|
||||
|
||||
//
|
||||
// FSGuard implementation
|
||||
//
|
||||
|
||||
FSGuard::FSGuard(LittleFSHandler* fs_handler, const std::string& relative_path, const char* flags)
|
||||
: fs_handler_(fs_handler), file_(nullptr) {
|
||||
if (fs_handler_ != nullptr) {
|
||||
fs_handler_->open_file_(relative_path, flags, file_);
|
||||
} else {
|
||||
ESP_LOGE("FSGuard", "FSGuard initialized with null LittleFSHandler");
|
||||
}
|
||||
}
|
||||
|
||||
FSGuard::~FSGuard() {
|
||||
this->close();
|
||||
}
|
||||
|
||||
esp_err_t FSGuard::close() {
|
||||
if (file_ != nullptr && fs_handler_ != nullptr) {
|
||||
esp_err_t err = fs_handler_->close_file_(file_);
|
||||
file_ = nullptr;
|
||||
fs_handler_ = nullptr;
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE("FSGuard", "Error closing file: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
//
|
||||
// LittleFSHandler implementation
|
||||
//
|
||||
|
||||
LittleFSHandler::LittleFSHandler() {
|
||||
this->fs_mutex_ = xSemaphoreCreateMutex();
|
||||
if (this->fs_mutex_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create filesystem mutex");
|
||||
}
|
||||
}
|
||||
|
||||
LittleFSHandler::~LittleFSHandler() {
|
||||
if (this->is_initialized_()) {
|
||||
esp_vfs_littlefs_unregister(PARTITION_LABEL);
|
||||
this->initialized_ = false;
|
||||
}
|
||||
if (this->fs_mutex_ != nullptr) {
|
||||
vSemaphoreDelete(this->fs_mutex_);
|
||||
this->fs_mutex_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::init(std::string base_path) {
|
||||
// default config
|
||||
esp_vfs_littlefs_conf_t config = {};
|
||||
config.dont_mount = false;
|
||||
config.partition_label = PARTITION_LABEL;
|
||||
config.base_path = base_path.c_str();
|
||||
config.format_if_mount_failed = true;
|
||||
//
|
||||
base_path_ = base_path;
|
||||
return init(config);
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::init(const esp_vfs_littlefs_conf_t& config) {
|
||||
base_path_ = std::string(config.base_path);
|
||||
if (this->is_initialized_()) {
|
||||
ESP_LOGW(TAG, "LittleFS already initialized, skipping");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_vfs_littlefs_register(&config);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize LittleFS: %s", esp_err_to_name(err));
|
||||
|
||||
if (err == ESP_ERR_NOT_FOUND) {
|
||||
ESP_LOGE(TAG, "Listing all available partitions:");
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
|
||||
while (it != NULL) {
|
||||
const esp_partition_t* part = esp_partition_get(it);
|
||||
ESP_LOGE(TAG, " - Label: '%s', Type: 0x%02x, Subtype: 0x%02x, Address: 0x%08x, Size: 0x%08x",
|
||||
part->label, part->type, part->subtype, part->address, part->size);
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
}
|
||||
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
this->initialized_ = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
std::string LittleFSHandler::get_base_path() const {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized, cannot get base path");
|
||||
return "";
|
||||
}
|
||||
return base_path_;
|
||||
}
|
||||
|
||||
std::string LittleFSHandler::get_full_path(const std::string& relative_path) const {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized, cannot get full path");
|
||||
return "";
|
||||
}
|
||||
return base_path_ + "/" + relative_path;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::write_file(const std::string& relative_path, const uint8_t* data, size_t size, size_t& out_bytes_written) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (data == nullptr) {
|
||||
ESP_LOGE(TAG, "Data pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
// Try to open with r+b first to preserve existing content for comparison
|
||||
FSGuard file_guard(this, relative_path, "r+b");
|
||||
|
||||
// If file doesn't exist, open with wb
|
||||
if (!file_guard.is_open()) {
|
||||
FSGuard new_file_guard(this, relative_path, "wb");
|
||||
if (!new_file_guard.is_open()) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
return this->write_if_different_(new_file_guard.get_file(), data, size, out_bytes_written);
|
||||
}
|
||||
return this->write_if_different_(file_guard.get_file(), data, size, out_bytes_written);
|
||||
}
|
||||
|
||||
|
||||
// Caller is responsible for opening the file in appropriate mode
|
||||
// If the file doesn't exist, use write_file with "wb" mode
|
||||
// If the file exists, use "r+b" mode to read and write
|
||||
esp_err_t LittleFSHandler::write_file(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written) {
|
||||
return this->write_if_different_(file, data, size, out_bytes_written);
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::append_file(const std::string& relative_path, const uint8_t* data, size_t size, size_t& out_bytes_written) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (data == nullptr) {
|
||||
ESP_LOGE(TAG, "Data pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
FSGuard file_guard(this, relative_path, "ab");
|
||||
FILE* file = file_guard.get_file();
|
||||
if (file == nullptr) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
return this->append_file(file, data, size, out_bytes_written);
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::append_file(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written) {
|
||||
if (file == nullptr) {
|
||||
ESP_LOGE(TAG, "File pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (data == nullptr) {
|
||||
ESP_LOGE(TAG, "Data pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
if (fseek(file, 0, SEEK_END) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to end of file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
// write data with POSIX
|
||||
size_t bytes_written = fwrite(data, 1, size, file);
|
||||
if (bytes_written != size) {
|
||||
ESP_LOGE(TAG, "Failed to write all data to file, expected %zu bytes, wrote %zu bytes", size, bytes_written);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
if (fflush(file) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to flush file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
out_bytes_written = bytes_written;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::read_file(const std::string& relative_path, const size_t max_size, uint8_t* out_data, size_t& out_size) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (out_data == nullptr) {
|
||||
ESP_LOGE(TAG, "Output data pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
FSGuard file_guard(this, relative_path, "rb");
|
||||
FILE* file = file_guard.get_file();
|
||||
if (file == nullptr) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
return this->read_file(file, max_size, out_data, out_size);
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::read_file(FILE* file, const size_t max_size, uint8_t* out_data, size_t& out_size) {
|
||||
if (file == nullptr) {
|
||||
ESP_LOGE(TAG, "File pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (out_data == nullptr) {
|
||||
ESP_LOGE(TAG, "Output data pointer is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
size_t bytes_read = fread(out_data, 1, max_size, file);
|
||||
if (bytes_read == 0 && ferror(file)) {
|
||||
ESP_LOGE(TAG, "Failed to read from file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
out_size = bytes_read;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::delete_file(const std::string& relative_path) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
if (remove(full_path.c_str()) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to delete file %s: %s", full_path.c_str(), strerror(errno));
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool LittleFSHandler::file_exists(const std::string& relative_path) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
struct stat st;
|
||||
return (stat(full_path.c_str(), &st) == 0 && S_ISREG(st.st_mode));
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::get_file_size(const std::string& relative_path, size_t& out_size) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
struct stat st;
|
||||
if (stat(full_path.c_str(), &st) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to stat file %s", full_path.c_str());
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
out_size = st.st_size;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::create_directory(const std::string& relative_path) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
if (mkdir(full_path.c_str(), 0755) != 0) {
|
||||
if (errno == EEXIST) {
|
||||
ESP_LOGW(TAG, "Directory %s already exists", full_path.c_str());
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_LOGE(TAG, "Failed to create directory %s: %s", full_path.c_str(), strerror(errno));
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::list_directory(const std::string& relative_path, std::vector<std::string>& out_entries) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
SemaphoreGuard guard(this->fs_mutex_);
|
||||
if (guard.take() != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Failed to acquire filesystem mutex");
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
DIR* dir = opendir(full_path.c_str());
|
||||
if (dir == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to open directory %s", full_path.c_str());
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
out_entries.clear();
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != nullptr) {
|
||||
// Skip . and ..
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
|
||||
continue;
|
||||
}
|
||||
out_entries.push_back(entry->d_name);
|
||||
}
|
||||
closedir(dir);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
//
|
||||
// Private methods
|
||||
//
|
||||
|
||||
esp_err_t LittleFSHandler::open_file_(const std::string& relative_path, const char* flags, FILE*& out_file) {
|
||||
if (!this->is_initialized_()) {
|
||||
ESP_LOGE(TAG, "LittleFS is not initialized, cannot open file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
std::string full_path = this->get_full_path(relative_path);
|
||||
FILE* file = fopen(full_path.c_str(), flags);
|
||||
if (file == nullptr) {
|
||||
// Use debug level if file doesn't exist (ENOENT), warning level for other errors
|
||||
if (errno == ENOENT) {
|
||||
ESP_LOGD(TAG, "File %s does not exist (flags %s)", full_path.c_str(), flags);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to open file %s with flags %s: %s", full_path.c_str(), flags, strerror(errno));
|
||||
}
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
out_file = file;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::close_file_(FILE* file) {
|
||||
if (file == nullptr) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (fclose(file) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to close file: %s", strerror(errno));
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::write_if_different_(FILE* file, const uint8_t* data, size_t size) {
|
||||
size_t out_bytes_written = 0;
|
||||
return this->write_if_different_(file, data, size, out_bytes_written);
|
||||
}
|
||||
|
||||
esp_err_t LittleFSHandler::write_if_different_(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written) {
|
||||
if (file == nullptr || data == nullptr) {
|
||||
ESP_LOGE(TAG, "Invalid parameters");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
// Get existing file size
|
||||
if (fseek(file, 0, SEEK_END) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to end of file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
long file_size_long = ftell(file);
|
||||
if (file_size_long < 0) {
|
||||
ESP_LOGE(TAG, "Failed to get file size");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
size_t file_size = (size_t)file_size_long;
|
||||
|
||||
if (fseek(file, 0, SEEK_SET) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to beginning of file");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
out_bytes_written = 0;
|
||||
size_t compare_size = (file_size < size) ? file_size : size;
|
||||
|
||||
// Read entire file content for comparison
|
||||
std::vector<uint8_t> existing_data;
|
||||
if (file_size > 0) {
|
||||
existing_data.resize(file_size);
|
||||
size_t bytes_read = fread(existing_data.data(), 1, file_size, file);
|
||||
if (bytes_read != file_size) {
|
||||
ESP_LOGE(TAG, "Failed to read existing file data");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare and identify blocks that need updating
|
||||
std::vector<bool> block_needs_update((size + BLOCK_SIZE - 1) / BLOCK_SIZE, false);
|
||||
bool any_changes = false;
|
||||
|
||||
for (size_t offset = 0; offset < compare_size; offset += BLOCK_SIZE) {
|
||||
size_t chunk_size = BLOCK_SIZE;
|
||||
if (offset + chunk_size > compare_size) {
|
||||
chunk_size = compare_size - offset;
|
||||
}
|
||||
|
||||
if (memcmp(existing_data.data() + offset, data + offset, chunk_size) != 0) {
|
||||
block_needs_update[offset / BLOCK_SIZE] = true;
|
||||
any_changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if size changed or there are additional blocks to write
|
||||
if (size != file_size) {
|
||||
any_changes = true;
|
||||
}
|
||||
|
||||
if (!any_changes) {
|
||||
ESP_LOGD(TAG, "File content unchanged, skipping write");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Seek to beginning to start writing
|
||||
if (fseek(file, 0, SEEK_SET) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to beginning for writing");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// Write only changed blocks
|
||||
for (size_t offset = 0; offset < compare_size; offset += BLOCK_SIZE) {
|
||||
size_t block_index = offset / BLOCK_SIZE;
|
||||
if (!block_needs_update[block_index]) {
|
||||
// Skip unchanged block
|
||||
if (fseek(file, offset + BLOCK_SIZE, SEEK_SET) != 0) {
|
||||
// If at end of compare region, this is OK
|
||||
if (offset + BLOCK_SIZE > compare_size) {
|
||||
if (fseek(file, compare_size, SEEK_SET) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek past unchanged block");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to seek past unchanged block at %zu", offset);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t chunk_size = BLOCK_SIZE;
|
||||
if (offset + chunk_size > compare_size) {
|
||||
chunk_size = compare_size - offset;
|
||||
}
|
||||
|
||||
if (fseek(file, offset, SEEK_SET) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to offset %zu", offset);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
size_t written = fwrite(data + offset, 1, chunk_size, file);
|
||||
if (written != chunk_size) {
|
||||
ESP_LOGE(TAG, "Failed to write block at offset %zu", offset);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
out_bytes_written += written;
|
||||
}
|
||||
|
||||
// Handle size differences
|
||||
if (size > file_size) {
|
||||
// Write additional data beyond original file size
|
||||
if (fseek(file, file_size, SEEK_SET) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to seek to end for appending");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
size_t written = fwrite(data + file_size, 1, size - file_size, file);
|
||||
if (written != (size - file_size)) {
|
||||
ESP_LOGE(TAG, "Failed to write additional data");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
out_bytes_written += written;
|
||||
} else if (size < file_size) {
|
||||
// Truncate file to new size
|
||||
if (ftruncate(fileno(file), size) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to truncate file to size %zu", size);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush to ensure data is written to storage
|
||||
if (fflush(file) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to flush file after write");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool LittleFSHandler::is_initialized_() const {
|
||||
return this->initialized_;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
95
main/io/fs_handler.h
Normal file
95
main/io/fs_handler.h
Normal file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_littlefs.h"
|
||||
#include "esp_err.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include "esp_log.h"
|
||||
#include <semaphore_guard.h>
|
||||
|
||||
// Forward declaration
|
||||
class LittleFSHandler;
|
||||
|
||||
class FSGuard {
|
||||
public:
|
||||
FSGuard(LittleFSHandler* fs_handler, const std::string& relative_path, const char* flags);
|
||||
~FSGuard();
|
||||
|
||||
esp_err_t close();
|
||||
|
||||
FILE* get_file() {
|
||||
return file_;
|
||||
}
|
||||
|
||||
bool is_open() const {
|
||||
return file_ != nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
LittleFSHandler* fs_handler_ = nullptr;
|
||||
FILE* file_;
|
||||
|
||||
// prevent copying and moving
|
||||
FSGuard(const FSGuard&) = delete;
|
||||
FSGuard& operator=(const FSGuard&) = delete;
|
||||
FSGuard(FSGuard&& other) = delete;
|
||||
FSGuard& operator=(FSGuard&& other) = delete;
|
||||
};
|
||||
|
||||
//LittleFSHandler interface
|
||||
// All paths are relative to the mounted filesystem root
|
||||
// Implementations should handle initialization of the filesystem, and mounting if necessary
|
||||
// When destroyed, implementations should unmount the filesystem if necessary
|
||||
// All paths are relative to the mounted filesystem root, e.g. if mounted at /littlefs, and file is /data.txt, the full path is /littlefs/data.txt
|
||||
// File operations use standard C FILE* wrapped in FSGuard for RAII
|
||||
class LittleFSHandler {
|
||||
public:
|
||||
LittleFSHandler();
|
||||
~LittleFSHandler();
|
||||
|
||||
esp_err_t init(std::string base_path);
|
||||
esp_err_t init(const esp_vfs_littlefs_conf_t& config);
|
||||
std::string get_base_path() const;
|
||||
std::string get_full_path(const std::string& relative_path) const;
|
||||
// File operations
|
||||
esp_err_t write_file(const std::string& relative_path, const uint8_t* data, size_t size, size_t& out_bytes_written);
|
||||
esp_err_t write_file(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written);
|
||||
//
|
||||
esp_err_t append_file(const std::string& relative_path, const uint8_t* data, size_t size, size_t& out_bytes_written);
|
||||
esp_err_t append_file(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written);
|
||||
//
|
||||
esp_err_t read_file(const std::string& relative_path, const size_t max_size, uint8_t* out_data, size_t& out_size);
|
||||
esp_err_t read_file(FILE* file, const size_t max_size, uint8_t* out_data, size_t& out_size);
|
||||
//
|
||||
esp_err_t delete_file(const std::string& relative_path);
|
||||
//
|
||||
bool file_exists(const std::string& relative_path);
|
||||
esp_err_t get_file_size(const std::string& relative_path, size_t& out_size);
|
||||
// Directory operations
|
||||
esp_err_t create_directory(const std::string& relative_path);
|
||||
esp_err_t list_directory(const std::string& relative_path, std::vector<std::string>& out_entries);
|
||||
|
||||
protected:
|
||||
|
||||
esp_err_t open_file_(const std::string& relative_path, const char* flags, FILE*& out_file);
|
||||
esp_err_t close_file_(FILE* file);
|
||||
|
||||
// uses standard C FILE* for file operations
|
||||
esp_err_t write_if_different_(FILE* file, const uint8_t* data, size_t size);
|
||||
esp_err_t write_if_different_(FILE* file, const uint8_t* data, size_t size, size_t& out_bytes_written);
|
||||
|
||||
friend class FSGuard;
|
||||
|
||||
private:
|
||||
//
|
||||
bool is_initialized_() const;
|
||||
|
||||
SemaphoreHandle_t fs_mutex_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
std::string base_path_;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,14 +13,12 @@
|
||||
#include "common/constants.h"
|
||||
#include "common/queue_defs.h"
|
||||
#include "io/nvs_handler.h"
|
||||
#include "io/fs_handler.h"
|
||||
#include "info/info.h"
|
||||
#include "display/eink_display_handler.h"
|
||||
#include "display/lvgl_handler.h"
|
||||
#include "ui/ui_handler.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "ui/apps/shutdown_app.h"
|
||||
#include "ui/apps/discord_app.h"
|
||||
#include "ui/apps/mtr_app.h"
|
||||
#include "ui/apps/registry.h"
|
||||
#include <tick/lv_tick.h>
|
||||
#include "esp_lvgl_port.h"
|
||||
#include "lvgl.h"
|
||||
@@ -31,7 +29,6 @@
|
||||
|
||||
// nvs storage namespaces, 15 characters max
|
||||
#define DEFAULT_STORAGE_NAMESPACE "storage"
|
||||
#define WIFI_CREDENTIALS_STORAGE_NAMESPACE "wifi_cred"
|
||||
#define TAG "Main"
|
||||
|
||||
extern "C" void app_main(void);
|
||||
@@ -61,9 +58,15 @@ void app_main(void) {
|
||||
DEFAULT_STORAGE_NAMESPACE
|
||||
);
|
||||
|
||||
auto wifi_handler = std::make_unique<WifiHandler>(
|
||||
std::unique_ptr<KVStorageHandler>(new NVSStorageHandler(WIFI_CREDENTIALS_STORAGE_NAMESPACE))
|
||||
);
|
||||
auto fs_handler = std::make_shared<LittleFSHandler>();
|
||||
esp_err_t fs_err = fs_handler->init("/littlefs");
|
||||
if (fs_err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize LittleFS: %s", esp_err_to_name(fs_err));
|
||||
vTaskDelay(5000 / portTICK_PERIOD_MS);
|
||||
return esp_restart();
|
||||
}
|
||||
|
||||
auto wifi_handler = std::make_unique<WifiHandler>(fs_handler);
|
||||
NetworkHandler* network_handler = new NetworkHandler(std::move(wifi_handler));
|
||||
EInkDisplayHandler* display_handler = new EInkDisplayHandler();
|
||||
// Initialize display and touch
|
||||
@@ -97,7 +100,7 @@ void app_main(void) {
|
||||
);
|
||||
ESP_LOGI(TAG, "System is ready. Starting main application...\n");
|
||||
|
||||
DiscordAppDescriptor::instance();
|
||||
// DiscordAppDescriptor::instance();
|
||||
UIHandler ui_handler;
|
||||
err = ui_handler.init();
|
||||
if (err != ESP_OK) {
|
||||
@@ -110,61 +113,6 @@ void app_main(void) {
|
||||
// Allow LVGL system to stabilize before creating objects
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
// Create main screen and button for random rectangle demo
|
||||
// lv_obj_t* scr = lv_scr_act();
|
||||
|
||||
// // Create a button
|
||||
// lv_obj_t* btn = lv_btn_create(scr);
|
||||
// lv_obj_set_size(btn, 200, 60);
|
||||
// lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 20);
|
||||
// lv_obj_set_style_border_width(btn, 2, 0);
|
||||
// lv_obj_set_style_border_color(btn, lv_color_make(0, 0, 0), 0);
|
||||
|
||||
// // Add label to button
|
||||
// lv_obj_t* label = lv_label_create(btn);
|
||||
// lv_label_set_text(label, "Create Random Rect");
|
||||
// lv_obj_center(label);
|
||||
// lv_obj_set_style_text_color(label, lv_color_make(0, 0, 0), 0);
|
||||
|
||||
// // Event handler for button - creates random rectangles
|
||||
// auto btn_event_cb = [](lv_event_t* e) {
|
||||
// lv_obj_t* scr = lv_scr_act();
|
||||
|
||||
// // Create a random rectangle
|
||||
// lv_obj_t* rect = lv_obj_create(scr);
|
||||
|
||||
// // Random size (30-100 pixels)
|
||||
// lv_coord_t width = 30 + (esp_random() % 70);
|
||||
// lv_coord_t height = 30 + (esp_random() % 70);
|
||||
// lv_obj_set_size(rect, width, height);
|
||||
|
||||
// // Random position (avoid top 100px where button is)
|
||||
// lv_coord_t x = esp_random() % (LV_HOR_RES - width);
|
||||
// lv_coord_t y = 100 + (esp_random() % (LV_VER_RES - 100 - height));
|
||||
// lv_obj_set_pos(rect, x, y);
|
||||
|
||||
// lv_obj_set_style_bg_color(rect, lv_color_make(0, 0, 0), 0);
|
||||
// lv_obj_set_style_bg_opa(rect, LV_OPA_COVER, 0);
|
||||
|
||||
// // Make rectangle clickable
|
||||
// lv_obj_add_flag(rect, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// // Event handler to delete rectangle when clicked
|
||||
// auto rect_event_cb = [](lv_event_t* e) {
|
||||
// lv_obj_t* rect = static_cast<lv_obj_t*>(lv_event_get_target(e));
|
||||
// lv_obj_del(rect);
|
||||
// ESP_LOGI(TAG, "Rectangle deleted");
|
||||
// };
|
||||
|
||||
// lv_obj_add_event_cb(rect, rect_event_cb, LV_EVENT_CLICKED, NULL);
|
||||
|
||||
// ESP_LOGI(TAG, "Created rectangle at (%d, %d) with size %dx%d", x, y, width, height);
|
||||
// };
|
||||
|
||||
// lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
|
||||
|
||||
// ESP_LOGI(TAG, "Random rectangle demo initialized. Tap button to create rectangles.\n");
|
||||
|
||||
// wait for shutdown signal
|
||||
ESP_LOGI(TAG, "Waiting for shutdown signal...\n");
|
||||
EventBits_t bits = xEventGroupWaitBits(
|
||||
|
||||
@@ -8,16 +8,27 @@
|
||||
#include "common/semaphore_guard.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
static const char* TAG = "WifiHandler";
|
||||
static const char* WIFI_SSID_KEY = "ssid";
|
||||
static const char* WIFI_PASSWORD_STORE_KEY = "psw";
|
||||
#define TAG "WifiHandler"
|
||||
#define WIFI_CRED_FILE_PATH "wifi_credentials.json"
|
||||
|
||||
/*
|
||||
* WiFi Credentials JSON Structure:
|
||||
* {
|
||||
* "current_ssid": "MyWiFi",
|
||||
* "credentials": {
|
||||
* "MyWiFi": {
|
||||
* "password": "mypassword123"
|
||||
* },
|
||||
* "OtherNetwork": {
|
||||
* "password": "otherpass456"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
WifiHandler::WifiHandler(
|
||||
// this handler is used to store/retrieve WiFi credentials
|
||||
// should have a unique namespace for WiFi credentials
|
||||
// it will be owned by WifiHandler and deleted in its destructor
|
||||
std::unique_ptr<KVStorageHandler> kvs
|
||||
) : kvs(std::move(kvs)) {
|
||||
std::shared_ptr<LittleFSHandler> fs_handler_
|
||||
) : fs_handler_(std::move(fs_handler_)) {
|
||||
this->s_wifi_event_group = xEventGroupCreate();
|
||||
if (!this->s_wifi_event_group) {
|
||||
ESP_LOGE(TAG, "Failed to create WiFi event group");
|
||||
@@ -34,10 +45,13 @@ WifiHandler::WifiHandler(
|
||||
if (!this->credential_mutex) {
|
||||
ESP_LOGE(TAG, "Failed to create credential mutex");
|
||||
}
|
||||
if (this->kvs == nullptr) {
|
||||
ESP_LOGW(TAG, "KVStorageHandler is null, WiFi credentials will not be stored");
|
||||
if (this->fs_handler_ == nullptr) {
|
||||
ESP_LOGW(TAG, "FSHandler is null, WiFi credentials will not be stored");
|
||||
} else {
|
||||
this->kvs->init(nullptr);
|
||||
esp_err_t err = this->fs_handler_->init("/littlefs");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize FSHandler: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +69,8 @@ WifiHandler::~WifiHandler() {
|
||||
esp_event_handler_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, &WifiHandler::wifi_event_handler);
|
||||
esp_event_handler_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, &WifiHandler::wifi_event_handler);
|
||||
this->initialized = false;
|
||||
// unique_ptr will automatically delete the object
|
||||
this->kvs = nullptr;
|
||||
//
|
||||
this->fs_handler_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,8 +337,8 @@ void WifiHandler::wifi_event_handler(void* arg, esp_event_base_t event_base, int
|
||||
//
|
||||
|
||||
void WifiHandler::store_wifi_credentials(const std::string& ssid, const std::string& password) {
|
||||
if (!kvs) {
|
||||
ESP_LOGW(TAG, "KVStorageHandler not set, cannot store WiFi credentials");
|
||||
if (!fs_handler_) {
|
||||
ESP_LOGW(TAG, "FSHandler not set, cannot store WiFi credentials");
|
||||
return;
|
||||
}
|
||||
SemaphoreGuard guard(this->credential_mutex);
|
||||
@@ -334,36 +348,52 @@ void WifiHandler::store_wifi_credentials(const std::string& ssid, const std::str
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current SSID
|
||||
kvs->put(WIFI_SSID_KEY, ssid);
|
||||
|
||||
// Store the password according to the JSON structure
|
||||
std::string password_key_store = kvs->get(WIFI_PASSWORD_STORE_KEY);
|
||||
cJSON* json = nullptr;
|
||||
if (password_key_store.empty()) {
|
||||
// create new JSON object
|
||||
json = cJSON_CreateObject();
|
||||
} else {
|
||||
// parse existing JSON
|
||||
json = cJSON_Parse(password_key_store.c_str());
|
||||
if (json == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to parse existing WiFi password JSON, creating new");
|
||||
json = cJSON_CreateObject();
|
||||
|
||||
// Try to read existing credentials file
|
||||
if (fs_handler_->file_exists(WIFI_CRED_FILE_PATH)) {
|
||||
// Read existing file
|
||||
size_t file_size = 0;
|
||||
esp_err_t err = fs_handler_->get_file_size(WIFI_CRED_FILE_PATH, file_size);
|
||||
if (err == ESP_OK && file_size > 0) {
|
||||
std::vector<uint8_t> file_data(file_size + 1); // +1 for null terminator
|
||||
size_t bytes_read = 0;
|
||||
err = fs_handler_->read_file(WIFI_CRED_FILE_PATH, file_size, file_data.data(), bytes_read);
|
||||
if (err == ESP_OK) {
|
||||
file_data[bytes_read] = '\0'; // Null terminate
|
||||
json = cJSON_Parse(reinterpret_cast<const char*>(file_data.data()));
|
||||
if (json == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to parse existing WiFi credentials JSON, creating new");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new JSON if parsing failed or file doesn't exist
|
||||
if (json == nullptr) {
|
||||
json = cJSON_CreateObject();
|
||||
}
|
||||
|
||||
// Set current SSID
|
||||
cJSON* current_ssid_item = cJSON_GetObjectItem(json, "current_ssid");
|
||||
if (current_ssid_item != nullptr) {
|
||||
cJSON_SetValuestring(current_ssid_item, ssid.c_str());
|
||||
} else {
|
||||
cJSON_AddStringToObject(json, "current_ssid", ssid.c_str());
|
||||
}
|
||||
|
||||
// Get or create credentials object
|
||||
cJSON* credentials = cJSON_GetObjectItem(json, "credentials");
|
||||
if (credentials == nullptr || !cJSON_IsObject(credentials)) {
|
||||
// create credentials object if it doesn't exist
|
||||
credentials = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(json, "credentials", credentials);
|
||||
}
|
||||
|
||||
// Limit stored credentials to prevent NVS overflow (keep max 10 SSIDs)
|
||||
// Limit stored credentials to prevent excessive file size (keep max 10 SSIDs)
|
||||
int credential_count = cJSON_GetArraySize(credentials);
|
||||
if (credential_count >= 10) {
|
||||
ESP_LOGW(TAG, "Too many stored credentials (%d), clearing old ones", credential_count);
|
||||
// Keep only the current SSID's credentials, clear others
|
||||
cJSON_DeleteItemFromObject(credentials, ssid.c_str()); // Remove if exists
|
||||
ESP_LOGW(TAG, "Too many stored credentials (%d), keeping only current SSID", credential_count);
|
||||
// Keep only the current SSID's credentials
|
||||
cJSON* new_credentials = cJSON_CreateObject();
|
||||
cJSON_ReplaceItemInObject(json, "credentials", new_credentials);
|
||||
credentials = new_credentials;
|
||||
@@ -372,20 +402,27 @@ void WifiHandler::store_wifi_credentials(const std::string& ssid, const std::str
|
||||
// Remove existing entry for this SSID to update it
|
||||
cJSON_DeleteItemFromObject(credentials, ssid.c_str());
|
||||
|
||||
// create SSID object
|
||||
// Create SSID object with password
|
||||
cJSON* ssid_item = cJSON_CreateObject();
|
||||
// add password field
|
||||
cJSON_AddStringToObject(ssid_item, "password", password.c_str());
|
||||
// add SSID object to credentials
|
||||
cJSON_AddItemToObject(credentials, ssid.c_str(), ssid_item);
|
||||
|
||||
// store updated JSON string
|
||||
char* updated_json_str = cJSON_PrintUnformatted(json);
|
||||
if (updated_json_str) {
|
||||
esp_err_t err = ESP_OK;
|
||||
kvs->put(WIFI_PASSWORD_STORE_KEY, std::string(updated_json_str));
|
||||
// Note: Error handling is done in nvs_handler.cpp put() method
|
||||
cJSON_free(updated_json_str);
|
||||
// Serialize and write to file
|
||||
char* json_str = cJSON_PrintUnformatted(json);
|
||||
if (json_str) {
|
||||
size_t bytes_written = 0;
|
||||
esp_err_t err = fs_handler_->write_file(
|
||||
WIFI_CRED_FILE_PATH,
|
||||
reinterpret_cast<const uint8_t*>(json_str),
|
||||
strlen(json_str),
|
||||
bytes_written
|
||||
);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to write WiFi credentials to file: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Stored WiFi credentials for SSID: %s", ssid.c_str());
|
||||
}
|
||||
cJSON_free(json_str);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to serialize WiFi credentials JSON");
|
||||
}
|
||||
@@ -393,59 +430,104 @@ void WifiHandler::store_wifi_credentials(const std::string& ssid, const std::str
|
||||
}
|
||||
|
||||
void WifiHandler::get_wifi_credentials(std::string& out_ssid, std::string& out_password) {
|
||||
if (!kvs) {
|
||||
ESP_LOGW(TAG, "KVStorageHandler not set, cannot get WiFi credentials");
|
||||
return;
|
||||
}
|
||||
SemaphoreGuard guard(this->credential_mutex);
|
||||
// wait up to 5 seconds to take the mutex
|
||||
if (!guard.take(5000 / portTICK_PERIOD_MS)) {
|
||||
ESP_LOGE(TAG, "Failed to take credential mutex");
|
||||
return;
|
||||
}
|
||||
out_ssid = kvs->get(WIFI_SSID_KEY);
|
||||
if (out_ssid.empty()) {
|
||||
if (!fs_handler_) {
|
||||
ESP_LOGW(TAG, "FSHandler not set, cannot get WiFi credentials");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
// password is from KV storage, may be nullptr
|
||||
std::string password_key_store = kvs->get(WIFI_PASSWORD_STORE_KEY);
|
||||
if (password_key_store.empty()) {
|
||||
|
||||
SemaphoreGuard guard(this->credential_mutex);
|
||||
// wait up to 5 seconds to take the mutex
|
||||
if (!guard.take(5000 / portTICK_PERIOD_MS)) {
|
||||
ESP_LOGE(TAG, "Failed to take credential mutex");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
// parse from json
|
||||
cJSON* json = cJSON_Parse(password_key_store.c_str());
|
||||
|
||||
// Check if credentials file exists
|
||||
if (!fs_handler_->file_exists(WIFI_CRED_FILE_PATH)) {
|
||||
ESP_LOGD(TAG, "WiFi credentials file does not exist");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Read credentials file
|
||||
size_t file_size = 0;
|
||||
esp_err_t err = fs_handler_->get_file_size(WIFI_CRED_FILE_PATH, file_size);
|
||||
if (err != ESP_OK || file_size == 0) {
|
||||
ESP_LOGE(TAG, "Failed to get WiFi credentials file size");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> file_data(file_size + 1); // +1 for null terminator
|
||||
size_t bytes_read = 0;
|
||||
err = fs_handler_->read_file(WIFI_CRED_FILE_PATH, file_size, file_data.data(), bytes_read);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to read WiFi credentials file: %s", esp_err_to_name(err));
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
file_data[bytes_read] = '\0'; // Null terminate
|
||||
|
||||
// Parse JSON
|
||||
cJSON* json = cJSON_Parse(reinterpret_cast<const char*>(file_data.data()));
|
||||
if (json == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to parse WiFi password JSON");
|
||||
ESP_LOGE(TAG, "Failed to parse WiFi credentials JSON");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current SSID
|
||||
cJSON* current_ssid_item = cJSON_GetObjectItem(json, "current_ssid");
|
||||
if (current_ssid_item == nullptr || !cJSON_IsString(current_ssid_item)) {
|
||||
ESP_LOGE(TAG, "WiFi credentials JSON does not contain valid 'current_ssid' field");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
cJSON_Delete(json);
|
||||
return;
|
||||
}
|
||||
out_ssid = current_ssid_item->valuestring;
|
||||
|
||||
// Get credentials object
|
||||
cJSON* credentials = cJSON_GetObjectItem(json, "credentials");
|
||||
if (credentials == nullptr || !cJSON_IsObject(credentials)) {
|
||||
ESP_LOGE(TAG, "WiFi password JSON does not contain valid 'credentials' object");
|
||||
ESP_LOGE(TAG, "WiFi credentials JSON does not contain valid 'credentials' object");
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
cJSON_Delete(json);
|
||||
return;
|
||||
}
|
||||
// get the ssid value
|
||||
|
||||
// Get SSID entry
|
||||
cJSON* ssid_item = cJSON_GetObjectItem(credentials, out_ssid.c_str());
|
||||
if (ssid_item == nullptr || !cJSON_IsObject(ssid_item)) {
|
||||
ESP_LOGE(TAG, "WiFi password JSON does not contain valid SSID field for SSID: %s", out_ssid.c_str());
|
||||
ESP_LOGE(TAG, "WiFi credentials JSON does not contain entry for SSID: %s", out_ssid.c_str());
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
cJSON_Delete(json);
|
||||
return;
|
||||
}
|
||||
cJSON* password = cJSON_GetObjectItem(ssid_item, "password");
|
||||
if (password == nullptr || !cJSON_IsString(password)) {
|
||||
ESP_LOGE(TAG, "WiFi password JSON does not contain valid 'password' field for SSID: %s", out_ssid.c_str());
|
||||
|
||||
// Get password
|
||||
cJSON* password_item = cJSON_GetObjectItem(ssid_item, "password");
|
||||
if (password_item == nullptr || !cJSON_IsString(password_item)) {
|
||||
ESP_LOGE(TAG, "WiFi credentials JSON does not contain valid 'password' field for SSID: %s", out_ssid.c_str());
|
||||
out_ssid = "";
|
||||
out_password = "";
|
||||
cJSON_Delete(json);
|
||||
return;
|
||||
}
|
||||
out_password = password->valuestring;
|
||||
out_password = password_item->valuestring;
|
||||
|
||||
cJSON_Delete(json);
|
||||
ESP_LOGD(TAG, "Retrieved WiFi credentials for SSID: %s", out_ssid.c_str());
|
||||
}
|
||||
|
||||
EventBits_t WifiHandler::wait_for_connection(TickType_t ticks_to_wait) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "io/io.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "io/fs_handler.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/event_groups.h"
|
||||
|
||||
@@ -13,7 +14,7 @@ public:
|
||||
// this handler is used to store/retrieve WiFi credentials
|
||||
// should have a unique namespace for WiFi credentials
|
||||
// it will be owned by WifiHandler and deleted in its destructor
|
||||
std::unique_ptr<KVStorageHandler> kvs
|
||||
std::shared_ptr<LittleFSHandler> fs_handler_
|
||||
);
|
||||
~WifiHandler();
|
||||
|
||||
@@ -44,7 +45,7 @@ private:
|
||||
void get_wifi_credentials(std::string& out_ssid, std::string& out_password);
|
||||
|
||||
bool initialized = false;
|
||||
std::unique_ptr<KVStorageHandler> kvs = nullptr;
|
||||
std::shared_ptr<LittleFSHandler> fs_handler_ = nullptr;
|
||||
EventGroupHandle_t s_wifi_event_group = 0;
|
||||
SemaphoreHandle_t scan_mutex = nullptr;
|
||||
SemaphoreHandle_t connection_mutex = nullptr;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
#include "ui/ui_app.h"
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Registry for all available apps
|
||||
*
|
||||
* This singleton class maintains a list of all registered
|
||||
* AppDescriptor instances, allowing the UIHandler or other
|
||||
* components to query available apps.
|
||||
*/
|
||||
class AppRegistry {
|
||||
public:
|
||||
static AppRegistry& instance() {
|
||||
static AppRegistry registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
AppRegistry(const AppRegistry&) = delete;
|
||||
void operator=(const AppRegistry&) = delete;
|
||||
AppRegistry(AppRegistry&&) = delete;
|
||||
void operator=(AppRegistry&&) = delete;
|
||||
|
||||
|
||||
// Register a new app descriptor
|
||||
// The registry takes ownership of the descriptor pointer.
|
||||
void register_app(AppDescriptor* app_descriptor) {
|
||||
_app_descriptors.push_back(app_descriptor);
|
||||
}
|
||||
|
||||
const std::vector<AppDescriptor*>& get_app_descriptors() const {
|
||||
return _app_descriptors;
|
||||
}
|
||||
|
||||
private:
|
||||
AppRegistry() = default;
|
||||
~AppRegistry() = default;
|
||||
std::vector<AppDescriptor*> _app_descriptors = {};
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "lvgl.h"
|
||||
#include "esp_err.h"
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @brief Base class for all UI applications
|
||||
@@ -48,51 +49,39 @@ public:
|
||||
virtual std::string get_name(void) const = 0;
|
||||
|
||||
/**
|
||||
* @brief Handle system events passed from UIHandler
|
||||
* @brief Handle back button press
|
||||
*
|
||||
* System events include network status changes, storage ready,
|
||||
* display refresh, and other system-level events.
|
||||
* Called when the back button is pressed.
|
||||
* The app can choose to handle it (e.g., close a dialog)
|
||||
* or return false to let UIHandler handle it (e.g., return to main screen).
|
||||
*
|
||||
* @param event_type Type/ID of the event
|
||||
* @param event_data Optional event data payload
|
||||
* @return true if the event was handled, false otherwise
|
||||
*/
|
||||
virtual void handle_event(uint32_t event_type, void* event_data = nullptr) { }
|
||||
|
||||
virtual bool on_back_button_pressed(void) {
|
||||
return false; // default: not handled
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the app's root container
|
||||
*
|
||||
* @return lv_obj_t* pointer to the app's container
|
||||
*/
|
||||
lv_obj_t* get_container(void) const {
|
||||
return _container;
|
||||
}
|
||||
|
||||
protected:
|
||||
lv_obj_t* _container = nullptr; ///< LVGL container provided by UIHandler
|
||||
lv_obj_t* container_ = nullptr; ///< LVGL container provided by UIHandler
|
||||
};
|
||||
|
||||
|
||||
class AppDescriptor {
|
||||
public:
|
||||
virtual ~AppDescriptor() = default;
|
||||
virtual void draw_icon(lv_obj_t* parent) = 0;
|
||||
|
||||
std::string get_name() const {
|
||||
return _name;
|
||||
return name_;
|
||||
}
|
||||
|
||||
UIApp* get_app_instance() const {
|
||||
return _app_instance;
|
||||
return app_instance_.get();
|
||||
}
|
||||
|
||||
protected:
|
||||
AppDescriptor(std::string name, UIApp* app_instance)
|
||||
: _name(name), _app_instance(app_instance) { }
|
||||
AppDescriptor(std::string name, std::unique_ptr<UIApp> app_instance)
|
||||
: name_(name), app_instance_(std::move(app_instance)) { }
|
||||
|
||||
std::string _name;
|
||||
UIApp* _app_instance;
|
||||
};
|
||||
std::string name_;
|
||||
std::unique_ptr<UIApp> app_instance_;
|
||||
};
|
||||
@@ -1,652 +0,0 @@
|
||||
#include "discord_app.h"
|
||||
#include "esp_log.h"
|
||||
#include "network/network.h"
|
||||
#include <sstream>
|
||||
|
||||
static const char* TAG = "DiscordApp";
|
||||
|
||||
// ============================================================================
|
||||
// DiscordApp Implementation
|
||||
// ============================================================================
|
||||
|
||||
DiscordApp::DiscordApp()
|
||||
: page_stack_(nullptr)
|
||||
, status_icon_label_(nullptr)
|
||||
, status_text_label_(nullptr)
|
||||
, mute_button_(nullptr)
|
||||
, error_notification_(nullptr)
|
||||
, ip_textarea_(nullptr)
|
||||
, port_textarea_(nullptr)
|
||||
, test_result_label_(nullptr)
|
||||
, remote_port_(0)
|
||||
, settings_configured_(false)
|
||||
, current_state_(VoiceState::UNKNOWN)
|
||||
, state_mutex_(nullptr)
|
||||
, poll_task_handle_(nullptr)
|
||||
, stop_polling_(false)
|
||||
, consecutive_failures_(0)
|
||||
, storage_(nullptr) {
|
||||
|
||||
// Create mutex for thread-safe state access
|
||||
state_mutex_ = xSemaphoreCreateMutex();
|
||||
|
||||
// Initialize storage
|
||||
storage_ = new NVSStorageHandler(NVS_NAMESPACE);
|
||||
}
|
||||
|
||||
DiscordApp::~DiscordApp() {
|
||||
stop_polling_task();
|
||||
|
||||
if (state_mutex_) {
|
||||
vSemaphoreDelete(state_mutex_);
|
||||
}
|
||||
|
||||
if (storage_) {
|
||||
delete storage_;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t DiscordApp::init(lv_obj_t* container) {
|
||||
ESP_LOGI(TAG, "Initializing Discord app");
|
||||
|
||||
_container = container;
|
||||
|
||||
// Initialize storage
|
||||
storage_->init(nullptr);
|
||||
|
||||
// Load saved settings
|
||||
load_settings();
|
||||
|
||||
// Initialize UDP client
|
||||
udp_client_.init();
|
||||
|
||||
// Configure UDP if settings are available
|
||||
if (settings_configured_) {
|
||||
udp_client_.configure(remote_ip_, remote_port_);
|
||||
}
|
||||
|
||||
// Create page stack
|
||||
page_stack_ = new PageStack(container);
|
||||
|
||||
// Build main page
|
||||
page_stack_->push([this](lv_obj_t* page) {
|
||||
build_main_page(page);
|
||||
});
|
||||
|
||||
// Start polling task
|
||||
start_polling_task();
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t DiscordApp::deinit() {
|
||||
ESP_LOGI(TAG, "Deinitializing Discord app");
|
||||
|
||||
// Stop polling
|
||||
stop_polling_task();
|
||||
|
||||
// Clean up page stack
|
||||
if (page_stack_) {
|
||||
delete page_stack_;
|
||||
page_stack_ = nullptr;
|
||||
}
|
||||
|
||||
// Close UDP client
|
||||
udp_client_.close();
|
||||
|
||||
// Reset widget pointers
|
||||
status_icon_label_ = nullptr;
|
||||
status_text_label_ = nullptr;
|
||||
mute_button_ = nullptr;
|
||||
error_notification_ = nullptr;
|
||||
ip_textarea_ = nullptr;
|
||||
port_textarea_ = nullptr;
|
||||
test_result_label_ = nullptr;
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void DiscordApp::handle_event(uint32_t event_type, void* event_data) {
|
||||
// Handle system events if needed
|
||||
}
|
||||
|
||||
bool DiscordApp::on_back_button_pressed() {
|
||||
// If on settings page, go back to main page
|
||||
if (page_stack_ && page_stack_->depth() > 1) {
|
||||
page_stack_->pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Let system handle back (return to app icons)
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page UI
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::build_main_page(lv_obj_t* page) {
|
||||
// Set up main page with flex column layout
|
||||
lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(page, 10, 0);
|
||||
|
||||
// === Top Section: Error Notification ===
|
||||
error_notification_ = lv_obj_create(page);
|
||||
lv_obj_set_width(error_notification_, LV_PCT(90));
|
||||
lv_obj_set_height(error_notification_, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_color(error_notification_, lv_color_hex(0xFF0000), 0);
|
||||
lv_obj_set_style_bg_opa(error_notification_, LV_OPA_70, 0);
|
||||
lv_obj_set_style_pad_all(error_notification_, 10, 0);
|
||||
lv_obj_set_style_radius(error_notification_, 8, 0);
|
||||
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_set_flex_flow(error_notification_, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(error_notification_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
lv_obj_t* error_label = lv_label_create(error_notification_);
|
||||
lv_label_set_text(error_label, LV_SYMBOL_WARNING " Connection Lost");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_white(), 0);
|
||||
|
||||
// === Center Section: Main Content ===
|
||||
lv_obj_t* center_container = lv_obj_create(page);
|
||||
lv_obj_set_size(center_container, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(center_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(center_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(center_container, 0, 0);
|
||||
lv_obj_set_flex_flow(center_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(center_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(center_container, 15, 0);
|
||||
lv_obj_set_flex_grow(center_container, 1);
|
||||
|
||||
// Status icon (large, centered)
|
||||
status_icon_label_ = lv_label_create(center_container);
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
|
||||
|
||||
// Status text
|
||||
status_text_label_ = lv_label_create(center_container);
|
||||
lv_label_set_text(status_text_label_, "Unknown Status");
|
||||
|
||||
// Mute button
|
||||
mute_button_ = lv_btn_create(center_container);
|
||||
lv_obj_set_size(mute_button_, 200, 60);
|
||||
lv_obj_add_event_cb(mute_button_, on_mute_button_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* mute_label = lv_label_create(mute_button_);
|
||||
lv_label_set_text(mute_label, "MUTE");
|
||||
lv_obj_center(mute_label);
|
||||
|
||||
// === Bottom Section: Settings and Config Prompt ===
|
||||
lv_obj_t* bottom_container = lv_obj_create(page);
|
||||
lv_obj_set_size(bottom_container, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(bottom_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(bottom_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(bottom_container, 0, 0);
|
||||
lv_obj_set_flex_flow(bottom_container, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(bottom_container, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
// Config prompt (left side)
|
||||
if (!settings_configured_) {
|
||||
lv_obj_t* config_prompt = lv_label_create(bottom_container);
|
||||
lv_label_set_text(config_prompt, "Tap " LV_SYMBOL_SETTINGS " to configure");
|
||||
lv_obj_set_style_text_color(config_prompt, lv_color_hex(0x888888), 0);
|
||||
} else {
|
||||
// Empty spacer if configured
|
||||
lv_obj_t* spacer = lv_obj_create(bottom_container);
|
||||
lv_obj_set_size(spacer, 0, 0);
|
||||
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(spacer, 0, 0);
|
||||
}
|
||||
|
||||
// Settings button (right side)
|
||||
lv_obj_t* settings_btn = lv_btn_create(bottom_container);
|
||||
lv_obj_set_size(settings_btn, 60, 60);
|
||||
lv_obj_add_event_cb(settings_btn, on_settings_button_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* settings_icon = lv_label_create(settings_btn);
|
||||
lv_label_set_text(settings_icon, LV_SYMBOL_SETTINGS);
|
||||
lv_obj_center(settings_icon);
|
||||
|
||||
// Update display with current state
|
||||
update_status_display();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Page UI
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::build_settings_page(lv_obj_t* page) {
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page);
|
||||
lv_label_set_text(title, "Discord Bridge Settings");
|
||||
// Using default font
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
// IP address label
|
||||
lv_obj_t* ip_label = lv_label_create(page);
|
||||
lv_label_set_text(ip_label, "Bridge IP Address:");
|
||||
lv_obj_align(ip_label, LV_ALIGN_TOP_LEFT, 20, 70);
|
||||
|
||||
// IP address textarea
|
||||
ip_textarea_ = lv_textarea_create(page);
|
||||
lv_obj_set_size(ip_textarea_, 300, 50);
|
||||
lv_obj_align(ip_textarea_, LV_ALIGN_TOP_LEFT, 20, 100);
|
||||
lv_textarea_set_one_line(ip_textarea_, true);
|
||||
lv_textarea_set_placeholder_text(ip_textarea_, "e.g., 192.168.1.100");
|
||||
|
||||
if (!remote_ip_.empty()) {
|
||||
lv_textarea_set_text(ip_textarea_, remote_ip_.c_str());
|
||||
}
|
||||
|
||||
// Port label
|
||||
lv_obj_t* port_label = lv_label_create(page);
|
||||
lv_label_set_text(port_label, "Bridge Port:");
|
||||
lv_obj_align(port_label, LV_ALIGN_TOP_LEFT, 20, 170);
|
||||
|
||||
// Port textarea
|
||||
port_textarea_ = lv_textarea_create(page);
|
||||
lv_obj_set_size(port_textarea_, 150, 50);
|
||||
lv_obj_align(port_textarea_, LV_ALIGN_TOP_LEFT, 20, 200);
|
||||
lv_textarea_set_one_line(port_textarea_, true);
|
||||
lv_textarea_set_placeholder_text(port_textarea_, "e.g., 4211");
|
||||
lv_textarea_set_accepted_chars(port_textarea_, "0123456789");
|
||||
lv_textarea_set_max_length(port_textarea_, 5);
|
||||
|
||||
if (remote_port_ > 0) {
|
||||
char port_str[8];
|
||||
snprintf(port_str, sizeof(port_str), "%u", remote_port_);
|
||||
lv_textarea_set_text(port_textarea_, port_str);
|
||||
}
|
||||
|
||||
// Test connection button
|
||||
lv_obj_t* test_btn = lv_btn_create(page);
|
||||
lv_obj_set_size(test_btn, 200, 50);
|
||||
lv_obj_align(test_btn, LV_ALIGN_TOP_MID, 0, 270);
|
||||
lv_obj_add_event_cb(test_btn, on_test_connection_clicked, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* test_label = lv_label_create(test_btn);
|
||||
lv_label_set_text(test_label, "Test Connection");
|
||||
lv_obj_center(test_label);
|
||||
|
||||
// Test result label
|
||||
test_result_label_ = lv_label_create(page);
|
||||
lv_label_set_text(test_result_label_, "");
|
||||
lv_obj_align(test_result_label_, LV_ALIGN_TOP_MID, 0, 330);
|
||||
|
||||
// Save button
|
||||
lv_obj_t* save_btn = lv_btn_create(page);
|
||||
lv_obj_set_size(save_btn, 150, 50);
|
||||
lv_obj_align(save_btn, LV_ALIGN_BOTTOM_MID, 0, -20);
|
||||
lv_obj_add_event_cb(save_btn, on_save_settings_clicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_style_bg_color(save_btn, lv_color_hex(0x00AA00), 0);
|
||||
|
||||
lv_obj_t* save_label = lv_label_create(save_btn);
|
||||
lv_label_set_text(save_label, LV_SYMBOL_SAVE " Save");
|
||||
lv_obj_set_style_text_color(save_label, lv_color_white(), 0);
|
||||
lv_obj_center(save_label);
|
||||
}
|
||||
|
||||
void DiscordApp::show_settings_page() {
|
||||
page_stack_->push([this](lv_obj_t* page) {
|
||||
build_settings_page(page);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Callbacks
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::on_mute_button_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->send_mute_command();
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_settings_button_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->show_settings_page();
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_save_settings_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (app) {
|
||||
app->save_settings();
|
||||
|
||||
// Go back to main page
|
||||
if (app->page_stack_->depth() > 1) {
|
||||
app->page_stack_->pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::on_test_connection_clicked(lv_event_t* e) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(lv_event_get_user_data(e));
|
||||
if (!app || !app->test_result_label_) return;
|
||||
|
||||
// Get values from textareas
|
||||
const char* ip = lv_textarea_get_text(app->ip_textarea_);
|
||||
const char* port_str = lv_textarea_get_text(app->port_textarea_);
|
||||
|
||||
if (strlen(ip) == 0 || strlen(port_str) == 0) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Please fill all fields");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t port = atoi(port_str);
|
||||
if (port == 0) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid port");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure UDP temporarily
|
||||
UDPClient test_client;
|
||||
test_client.init();
|
||||
esp_err_t err = test_client.configure(ip, port);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid IP address");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
lv_label_set_text(app->test_result_label_, "Testing...");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x0000FF), 0);
|
||||
|
||||
// Send STATUS command
|
||||
err = test_client.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Failed to send");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
std::string response;
|
||||
err = test_client.receive_response(response, 3000);
|
||||
|
||||
if (err == ESP_OK && (response == "MUTED" || response == "UNMUTED")) {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_OK " Connection successful!");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x00AA00), 0);
|
||||
} else {
|
||||
lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " No response from bridge");
|
||||
lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UDP Communication
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::send_mute_command() {
|
||||
if (!settings_configured_) {
|
||||
ESP_LOGW(TAG, "Cannot send command: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_err_t err = udp_client_.send_command("MUTE");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to send MUTE command");
|
||||
}
|
||||
}
|
||||
|
||||
bool DiscordApp::test_connection() {
|
||||
if (!settings_configured_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_err_t err = udp_client_.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string response;
|
||||
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
return (err == ESP_OK && (response == "MUTED" || response == "UNMUTED"));
|
||||
}
|
||||
|
||||
void DiscordApp::update_status_display() {
|
||||
if (!status_icon_label_ || !status_text_label_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Thread-safe state access
|
||||
VoiceState state;
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
state = current_state_;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case VoiceState::MUTED:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE);
|
||||
lv_label_set_text(status_text_label_, "Muted");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF0000), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::UNMUTED:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_VOLUME_MAX);
|
||||
lv_label_set_text(status_text_label_, "Unmuted");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x00AA00), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::ERROR:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_WARNING);
|
||||
lv_label_set_text(status_text_label_, "Connection Error");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF8800), 0);
|
||||
break;
|
||||
|
||||
case VoiceState::UNKNOWN:
|
||||
default:
|
||||
lv_label_set_text(status_icon_label_, LV_SYMBOL_BLUETOOTH);
|
||||
lv_label_set_text(status_text_label_, "Unknown Status");
|
||||
lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x888888), 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::show_error_notification(bool show) {
|
||||
if (error_notification_) {
|
||||
if (show) {
|
||||
lv_obj_clear_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Management
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::load_settings() {
|
||||
remote_ip_ = storage_->get(NVS_KEY_IP);
|
||||
std::string port_str = storage_->get(NVS_KEY_PORT);
|
||||
|
||||
if (!remote_ip_.empty() && !port_str.empty()) {
|
||||
remote_port_ = atoi(port_str.c_str());
|
||||
settings_configured_ = (remote_port_ > 0);
|
||||
ESP_LOGI(TAG, "Loaded settings: %s:%u", remote_ip_.c_str(), remote_port_);
|
||||
} else {
|
||||
settings_configured_ = false;
|
||||
ESP_LOGI(TAG, "No settings found, user setup required");
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::save_settings() {
|
||||
if (!ip_textarea_ || !port_textarea_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* ip = lv_textarea_get_text(ip_textarea_);
|
||||
const char* port_str = lv_textarea_get_text(port_textarea_);
|
||||
|
||||
if (strlen(ip) == 0 || strlen(port_str) == 0) {
|
||||
ESP_LOGW(TAG, "Cannot save: empty fields");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t port = atoi(port_str);
|
||||
if (port == 0) {
|
||||
ESP_LOGW(TAG, "Cannot save: invalid port");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to NVS
|
||||
storage_->put(NVS_KEY_IP, ip);
|
||||
storage_->put(NVS_KEY_PORT, port_str);
|
||||
|
||||
// Update local config
|
||||
remote_ip_ = ip;
|
||||
remote_port_ = port;
|
||||
settings_configured_ = true;
|
||||
|
||||
// Reconfigure UDP client
|
||||
udp_client_.configure(remote_ip_, remote_port_);
|
||||
|
||||
// Reset failure counter
|
||||
consecutive_failures_ = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Settings saved: %s:%u", remote_ip_.c_str(), remote_port_);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Polling Task
|
||||
// ============================================================================
|
||||
|
||||
void DiscordApp::poll_task(void* param) {
|
||||
DiscordApp* app = static_cast<DiscordApp*>(param);
|
||||
|
||||
ESP_LOGI(TAG, "Polling task started");
|
||||
|
||||
while (!app->stop_polling_) {
|
||||
app->poll_status();
|
||||
|
||||
// Use longer interval if in error state
|
||||
int interval = (app->consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR)
|
||||
? ERROR_POLL_INTERVAL_MS
|
||||
: POLL_INTERVAL_MS;
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(interval));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Polling task stopped");
|
||||
app->poll_task_handle_ = nullptr;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void DiscordApp::start_polling_task() {
|
||||
if (poll_task_handle_) {
|
||||
ESP_LOGW(TAG, "Polling task already running");
|
||||
return;
|
||||
}
|
||||
|
||||
stop_polling_ = false;
|
||||
xTaskCreate(poll_task, "discord_poll", 4096, this, 5, &poll_task_handle_);
|
||||
}
|
||||
|
||||
void DiscordApp::stop_polling_task() {
|
||||
if (!poll_task_handle_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Stopping polling task");
|
||||
stop_polling_ = true;
|
||||
|
||||
// Wait for task to finish (max 2 seconds)
|
||||
int wait_count = 0;
|
||||
while (poll_task_handle_ && wait_count < 20) {
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
wait_count++;
|
||||
}
|
||||
|
||||
if (poll_task_handle_) {
|
||||
ESP_LOGW(TAG, "Force deleting polling task");
|
||||
vTaskDelete(poll_task_handle_);
|
||||
poll_task_handle_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void DiscordApp::poll_status() {
|
||||
if (!settings_configured_) {
|
||||
// Don't poll if not configured
|
||||
return;
|
||||
}
|
||||
|
||||
// Send STATUS command
|
||||
esp_err_t err = udp_client_.send_command("STATUS");
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to send STATUS command");
|
||||
consecutive_failures_++;
|
||||
|
||||
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = VoiceState::ERROR;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
show_error_notification(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
std::string response;
|
||||
err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
// Success - reset failure counter
|
||||
consecutive_failures_ = 0;
|
||||
show_error_notification(false);
|
||||
|
||||
// Update state
|
||||
VoiceState new_state = VoiceState::UNKNOWN;
|
||||
if (response == "MUTED") {
|
||||
new_state = VoiceState::MUTED;
|
||||
} else if (response == "UNMUTED") {
|
||||
new_state = VoiceState::UNMUTED;
|
||||
}
|
||||
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = new_state;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
|
||||
update_status_display();
|
||||
|
||||
} else {
|
||||
// Timeout or error
|
||||
consecutive_failures_++;
|
||||
ESP_LOGW(TAG, "No response (failures: %d)", consecutive_failures_);
|
||||
|
||||
if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) {
|
||||
if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
current_state_ = VoiceState::ERROR;
|
||||
xSemaphoreGive(state_mutex_);
|
||||
}
|
||||
update_status_display();
|
||||
show_error_notification(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DiscordAppDescriptor Implementation
|
||||
// ============================================================================
|
||||
|
||||
DiscordAppDescriptor::DiscordAppDescriptor()
|
||||
: AppDescriptor("Discord", new DiscordApp()) {
|
||||
// Auto-register on construction
|
||||
AppRegistry::instance().register_app(this);
|
||||
}
|
||||
|
||||
void DiscordAppDescriptor::draw_icon(lv_obj_t* parent) {
|
||||
lv_obj_t* icon = lv_label_create(parent);
|
||||
lv_label_set_text(icon, LV_SYMBOL_CALL);
|
||||
lv_obj_center(icon);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/ui_app.h"
|
||||
#include "ui/page_stack.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "network/udp_client.h"
|
||||
#include "io/nvs_handler.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief Discord voice control app
|
||||
*
|
||||
* Allows control of Discord voice settings (mute/unmute) via UDP communication
|
||||
* with the IotDis Node.js bridge. Features:
|
||||
* - Main page: Status icon + mute button
|
||||
* - Settings page: IP/port configuration with connection test
|
||||
* - Periodic status polling with automatic retry
|
||||
* - Error notification when remote is unreachable
|
||||
*/
|
||||
class DiscordApp : public UIApp {
|
||||
public:
|
||||
DiscordApp();
|
||||
~DiscordApp() override;
|
||||
|
||||
// UIApp interface
|
||||
esp_err_t init(lv_obj_t* container) override;
|
||||
esp_err_t deinit() override;
|
||||
std::string get_name() const override { return "Discord"; }
|
||||
void handle_event(uint32_t event_type, void* event_data = nullptr) override;
|
||||
bool on_back_button_pressed() override;
|
||||
|
||||
private:
|
||||
// Voice state enum
|
||||
enum class VoiceState {
|
||||
UNKNOWN,
|
||||
MUTED,
|
||||
UNMUTED,
|
||||
ERROR
|
||||
};
|
||||
|
||||
// Page management
|
||||
PageStack* page_stack_;
|
||||
void build_main_page(lv_obj_t* page);
|
||||
void build_settings_page(lv_obj_t* page);
|
||||
void show_settings_page();
|
||||
|
||||
// Main page widgets
|
||||
lv_obj_t* status_icon_label_;
|
||||
lv_obj_t* status_text_label_;
|
||||
lv_obj_t* mute_button_;
|
||||
lv_obj_t* error_notification_;
|
||||
|
||||
// Settings page widgets
|
||||
lv_obj_t* ip_textarea_;
|
||||
lv_obj_t* port_textarea_;
|
||||
lv_obj_t* test_result_label_;
|
||||
|
||||
// UDP client and configuration
|
||||
UDPClient udp_client_;
|
||||
std::string remote_ip_;
|
||||
uint16_t remote_port_;
|
||||
bool settings_configured_;
|
||||
|
||||
// Voice state
|
||||
VoiceState current_state_;
|
||||
SemaphoreHandle_t state_mutex_;
|
||||
|
||||
// Polling task
|
||||
TaskHandle_t poll_task_handle_;
|
||||
bool stop_polling_;
|
||||
int consecutive_failures_;
|
||||
static constexpr int MAX_FAILURES_BEFORE_ERROR = 3;
|
||||
static constexpr int POLL_INTERVAL_MS = 5000;
|
||||
static constexpr int ERROR_POLL_INTERVAL_MS = 15000;
|
||||
static constexpr int RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
// NVS storage
|
||||
NVSStorageHandler* storage_;
|
||||
static constexpr const char* NVS_NAMESPACE = "discord";
|
||||
static constexpr const char* NVS_KEY_IP = "remote_ip";
|
||||
static constexpr const char* NVS_KEY_PORT = "remote_port";
|
||||
|
||||
// Event callbacks
|
||||
static void on_mute_button_clicked(lv_event_t* e);
|
||||
static void on_settings_button_clicked(lv_event_t* e);
|
||||
static void on_save_settings_clicked(lv_event_t* e);
|
||||
static void on_test_connection_clicked(lv_event_t* e);
|
||||
|
||||
// UDP communication
|
||||
void send_mute_command();
|
||||
bool test_connection();
|
||||
void update_status_display();
|
||||
void show_error_notification(bool show);
|
||||
|
||||
// Settings management
|
||||
void load_settings();
|
||||
void save_settings();
|
||||
|
||||
// Polling task
|
||||
static void poll_task(void* param);
|
||||
void start_polling_task();
|
||||
void stop_polling_task();
|
||||
void poll_status();
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Discord app descriptor for registration
|
||||
*/
|
||||
class DiscordAppDescriptor : public AppDescriptor {
|
||||
public:
|
||||
static DiscordAppDescriptor& instance() {
|
||||
static DiscordAppDescriptor instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void draw_icon(lv_obj_t* parent) override;
|
||||
|
||||
private:
|
||||
DiscordAppDescriptor();
|
||||
};
|
||||
@@ -1,399 +0,0 @@
|
||||
#include "apps/mtr_app.h"
|
||||
#include "external/mtr/arrival.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define TAG "MtrApp"
|
||||
|
||||
// Event type for network ready
|
||||
#define EVENT_NETWORK_READY 1
|
||||
|
||||
MtrApp::MtrApp() {
|
||||
_mtr_handler = std::make_unique<MTRNextTrainHandler>();
|
||||
}
|
||||
|
||||
esp_err_t MtrApp::init(lv_obj_t* container) {
|
||||
if (!container) {
|
||||
ESP_LOGE(TAG, "Container is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
_container = container;
|
||||
ESP_LOGI(TAG, "Initializing MTR app...");
|
||||
|
||||
// Create page stack
|
||||
_page_stack = std::make_unique<PageStack>(container);
|
||||
|
||||
// Load all lines
|
||||
_all_lines = _mtr_handler->get_lines();
|
||||
ESP_LOGI(TAG, "Loaded %zu MTR lines", _all_lines.size());
|
||||
|
||||
// Build initial line selection page
|
||||
_page_stack->push([this](lv_obj_t* page) {
|
||||
this->build_line_selection_page(page);
|
||||
});
|
||||
|
||||
ESP_LOGI(TAG, "MTR app initialized successfully");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t MtrApp::deinit(void) {
|
||||
ESP_LOGI(TAG, "Deinitializing MTR app");
|
||||
|
||||
// Clear page stack
|
||||
if (_page_stack) {
|
||||
_page_stack->clear();
|
||||
_page_stack.reset();
|
||||
}
|
||||
|
||||
// Clear state
|
||||
_selected_line_code.clear();
|
||||
_selected_station_code.clear();
|
||||
_selected_line_info = nullptr;
|
||||
_all_lines.clear();
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
std::string MtrApp::get_name(void) const {
|
||||
return "MTR";
|
||||
}
|
||||
|
||||
bool MtrApp::on_back_button_pressed(void) {
|
||||
if (_page_stack && _page_stack->depth() > 1) {
|
||||
_page_stack->pop();
|
||||
return true; // Handled
|
||||
}
|
||||
return false; // Not handled, go back to main menu
|
||||
}
|
||||
|
||||
void MtrApp::handle_event(uint32_t event_type, void* event_data) {
|
||||
if (event_type == EVENT_NETWORK_READY) {
|
||||
ESP_LOGI(TAG, "Network ready event received");
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::build_line_selection_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building line selection page");
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text(title, "選擇路綫 Select Line");
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Scrollable container for line buttons
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
|
||||
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(scroll_container, 5, 0);
|
||||
lv_obj_set_style_pad_row(scroll_container, 8, 0);
|
||||
|
||||
// Create button for each line
|
||||
for (size_t i = 0; i < _all_lines.size(); i++) {
|
||||
LineInfo* line = &_all_lines[i];
|
||||
|
||||
lv_obj_t* btn = lv_btn_create(scroll_container);
|
||||
lv_obj_set_size(btn, lv_pct(95), 60);
|
||||
|
||||
// Set button color based on line color
|
||||
uint32_t color = parse_color_hex(line->color());
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(color), 0);
|
||||
|
||||
// Button label
|
||||
lv_obj_t* label = lv_label_create(btn);
|
||||
lv_label_set_text_fmt(label, "%s", line->code());
|
||||
lv_obj_set_style_text_color(label, lv_color_white(), 0);
|
||||
lv_obj_center(label);
|
||||
|
||||
// Store line pointer in user data
|
||||
lv_obj_add_event_cb(btn, line_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_user_data(btn, (void*)line);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Created %zu line buttons", _all_lines.size());
|
||||
}
|
||||
|
||||
void MtrApp::build_station_selection_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building station selection page for line: %s", _selected_line_code.c_str());
|
||||
|
||||
if (!_selected_line_info) {
|
||||
ESP_LOGE(TAG, "No line info selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Title with line code
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s 路綫車站", _selected_line_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Scrollable container for station buttons
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(85));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 40);
|
||||
lv_obj_set_flex_flow(scroll_container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(scroll_container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(scroll_container, 5, 0);
|
||||
lv_obj_set_style_pad_row(scroll_container, 6, 0);
|
||||
|
||||
// Create button for each station
|
||||
const std::vector<StationInfo>* stations = _selected_line_info->stations();
|
||||
for (size_t i = 0; i < stations->size(); i++) {
|
||||
const StationInfo* station = &(*stations)[i];
|
||||
|
||||
lv_obj_t* btn = lv_btn_create(scroll_container);
|
||||
lv_obj_set_size(btn, lv_pct(95), 50);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(0x4CAF50), 0);
|
||||
|
||||
// Button label with station name and code
|
||||
lv_obj_t* label = lv_label_create(btn);
|
||||
lv_label_set_text_fmt(label, "%s (%s)", station->name(), station->code());
|
||||
lv_obj_set_style_text_color(label, lv_color_white(), 0);
|
||||
lv_obj_center(label);
|
||||
|
||||
// Store station pointer in user data
|
||||
lv_obj_add_event_cb(btn, station_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
lv_obj_set_user_data(btn, (void*)station);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Created %zu station buttons", stations->size());
|
||||
}
|
||||
|
||||
void MtrApp::build_arrival_page(lv_obj_t* page_container) {
|
||||
ESP_LOGI(TAG, "Building arrival page");
|
||||
|
||||
// Title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
// Loading message
|
||||
lv_obj_t* loading_label = lv_label_create(page_container);
|
||||
lv_label_set_text(loading_label, "載入中... Loading...");
|
||||
lv_obj_set_style_text_color(loading_label, lv_color_black(), 0);
|
||||
lv_obj_center(loading_label);
|
||||
|
||||
// Refresh button
|
||||
lv_obj_t* refresh_btn = lv_btn_create(page_container);
|
||||
lv_obj_set_size(refresh_btn, 120, 50);
|
||||
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
|
||||
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
|
||||
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
|
||||
lv_obj_center(refresh_label);
|
||||
|
||||
// Load arrival data asynchronously
|
||||
load_arrival_data(page_container);
|
||||
}
|
||||
|
||||
void MtrApp::load_arrival_data(lv_obj_t* page_container) {
|
||||
if (!_network_handler) {
|
||||
ESP_LOGW(TAG, "Network handler not set, cannot fetch arrival data");
|
||||
// Update UI to show error
|
||||
lv_obj_t* error_label = lv_label_create(page_container);
|
||||
lv_label_set_text(error_label, "網絡未就緒\nNetwork not ready");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
|
||||
lv_obj_align(error_label, LV_ALIGN_CENTER, 0, -30);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Fetching arrival data for %s/%s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
|
||||
StationArrivalInfo* arrival_info = nullptr;
|
||||
MtrArrivalErrorCode error_code = _mtr_handler->get_next_arrival_info(
|
||||
_network_handler,
|
||||
_selected_line_code,
|
||||
_selected_station_code,
|
||||
arrival_info,
|
||||
Language::TC
|
||||
);
|
||||
|
||||
// Clear loading message
|
||||
lv_obj_clean(page_container);
|
||||
|
||||
// Recreate title
|
||||
lv_obj_t* title = lv_label_create(page_container);
|
||||
lv_label_set_text_fmt(title, "%s - %s", _selected_line_code.c_str(), _selected_station_code.c_str());
|
||||
lv_obj_set_style_text_color(title, lv_color_black(), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 10);
|
||||
|
||||
if (error_code != MtrArrivalErrorCode::NONE || !arrival_info) {
|
||||
ESP_LOGE(TAG, "Failed to fetch arrival info, error code: %d", (int)error_code);
|
||||
|
||||
lv_obj_t* error_label = lv_label_create(page_container);
|
||||
lv_label_set_text(error_label, "無法取得班次資料\nFailed to fetch arrival data");
|
||||
lv_obj_set_style_text_color(error_label, lv_color_black(), 0);
|
||||
lv_obj_center(error_label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create scrollable container for arrivals
|
||||
lv_obj_t* scroll_container = lv_obj_create(page_container);
|
||||
lv_obj_set_size(scroll_container, lv_pct(95), lv_pct(75));
|
||||
lv_obj_align(scroll_container, LV_ALIGN_TOP_MID, 0, 45);
|
||||
lv_obj_set_style_pad_all(scroll_container, 10, 0);
|
||||
|
||||
int y_offset = 0;
|
||||
|
||||
// Display UP direction trains
|
||||
lv_obj_t* up_header = lv_label_create(scroll_container);
|
||||
lv_label_set_text(up_header, "上行 UP:");
|
||||
lv_obj_set_style_text_color(up_header, lv_color_black(), 0);
|
||||
lv_obj_set_pos(up_header, 0, y_offset);
|
||||
y_offset += 30;
|
||||
|
||||
const std::vector<ArrivalInfo>* up_arrivals = arrival_info->up_arrivals();
|
||||
if (up_arrivals->empty()) {
|
||||
lv_obj_t* no_train = lv_label_create(scroll_container);
|
||||
lv_label_set_text(no_train, " 暫無班次 No trains");
|
||||
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
|
||||
lv_obj_set_pos(no_train, 10, y_offset);
|
||||
y_offset += 25;
|
||||
} else {
|
||||
for (const auto& arrival : *up_arrivals) {
|
||||
lv_obj_t* arrival_label = lv_label_create(scroll_container);
|
||||
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
|
||||
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
|
||||
lv_obj_set_pos(arrival_label, 10, y_offset);
|
||||
y_offset += 25;
|
||||
}
|
||||
}
|
||||
|
||||
y_offset += 10;
|
||||
|
||||
// Display DOWN direction trains
|
||||
lv_obj_t* down_header = lv_label_create(scroll_container);
|
||||
lv_label_set_text(down_header, "下行 DOWN:");
|
||||
lv_obj_set_style_text_color(down_header, lv_color_black(), 0);
|
||||
lv_obj_set_pos(down_header, 0, y_offset);
|
||||
y_offset += 30;
|
||||
|
||||
const std::vector<ArrivalInfo>* down_arrivals = arrival_info->down_arrivals();
|
||||
if (down_arrivals->empty()) {
|
||||
lv_obj_t* no_train = lv_label_create(scroll_container);
|
||||
lv_label_set_text(no_train, " 暫無班次 No trains");
|
||||
lv_obj_set_style_text_color(no_train, lv_color_hex(0x666666), 0);
|
||||
lv_obj_set_pos(no_train, 10, y_offset);
|
||||
y_offset += 25;
|
||||
} else {
|
||||
for (const auto& arrival : *down_arrivals) {
|
||||
lv_obj_t* arrival_label = lv_label_create(scroll_container);
|
||||
lv_label_set_text_fmt(arrival_label, " %s → %s", arrival.arrival_time(), arrival.destination());
|
||||
lv_obj_set_style_text_color(arrival_label, lv_color_black(), 0);
|
||||
lv_obj_set_pos(arrival_label, 10, y_offset);
|
||||
y_offset += 25;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (arrival_info != nullptr) {
|
||||
delete arrival_info;
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
lv_obj_t* refresh_btn = lv_btn_create(page_container);
|
||||
lv_obj_set_size(refresh_btn, 120, 50);
|
||||
lv_obj_align(refresh_btn, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_add_event_cb(refresh_btn, refresh_button_event_cb, LV_EVENT_CLICKED, this);
|
||||
|
||||
lv_obj_t* refresh_label = lv_label_create(refresh_btn);
|
||||
lv_label_set_text(refresh_label, LV_SYMBOL_REFRESH " 重新整理");
|
||||
lv_obj_set_style_text_color(refresh_label, lv_color_white(), 0);
|
||||
lv_obj_center(refresh_label);
|
||||
|
||||
ESP_LOGI(TAG, "Arrival data displayed successfully");
|
||||
}
|
||||
|
||||
uint32_t MtrApp::parse_color_hex(const char* hex_str) {
|
||||
if (!hex_str || hex_str[0] != '#') {
|
||||
return 0x808080; // Default gray
|
||||
}
|
||||
|
||||
// Skip the '#' character
|
||||
hex_str++;
|
||||
|
||||
uint32_t color = 0;
|
||||
sscanf(hex_str, "%" SCNx32, &color);
|
||||
return color;
|
||||
}
|
||||
|
||||
void MtrApp::line_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
|
||||
LineInfo* line = (LineInfo*)lv_obj_get_user_data(btn);
|
||||
|
||||
if (app && line) {
|
||||
ESP_LOGI(TAG, "Line selected: %s", line->code());
|
||||
app->_selected_line_code = line->code();
|
||||
app->_selected_line_info = line;
|
||||
|
||||
// Push station selection page
|
||||
app->_page_stack->push([app](lv_obj_t* page) {
|
||||
app->build_station_selection_page(page);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::station_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
|
||||
const StationInfo* station = (const StationInfo*)lv_obj_get_user_data(btn);
|
||||
|
||||
if (app && station) {
|
||||
ESP_LOGI(TAG, "Station selected: %s (%s)", station->name(), station->code());
|
||||
app->_selected_station_code = station->code();
|
||||
|
||||
// Push arrival page
|
||||
app->_page_stack->push([app](lv_obj_t* page) {
|
||||
app->build_arrival_page(page);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MtrApp::refresh_button_event_cb(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_CLICKED) {
|
||||
MtrApp* app = (MtrApp*)lv_event_get_user_data(e);
|
||||
if (app && app->_page_stack && app->_page_stack->current_page()) {
|
||||
ESP_LOGI(TAG, "Refresh button clicked");
|
||||
app->load_arrival_data(app->_page_stack->current_page());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MtrAppDescriptor implementation
|
||||
MtrApp* MtrAppDescriptor::_app_instance = nullptr;
|
||||
|
||||
MtrAppDescriptor::MtrAppDescriptor()
|
||||
: AppDescriptor("MTR", []() -> UIApp* {
|
||||
if (!MtrAppDescriptor::_app_instance) {
|
||||
MtrAppDescriptor::_app_instance = new MtrApp();
|
||||
}
|
||||
return MtrAppDescriptor::_app_instance;
|
||||
}()) {
|
||||
// Register with AppRegistry
|
||||
AppRegistry::instance().register_app(this);
|
||||
ESP_LOGI(TAG, "MtrApp registered with AppRegistry");
|
||||
}
|
||||
|
||||
void MtrAppDescriptor::draw_icon(lv_obj_t* parent) {
|
||||
// Create MTR icon with train symbol
|
||||
lv_obj_t* icon_label = lv_label_create(parent);
|
||||
lv_label_set_text(icon_label, LV_SYMBOL_GPS "\nMTR");
|
||||
lv_obj_set_style_text_color(icon_label, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(icon_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_center(icon_label);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/ui_app.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "ui/page_stack.h"
|
||||
#include "external/mtr/mtr.h"
|
||||
#include "external/mtr/line_info.h"
|
||||
#include "external/mtr/station_info.h"
|
||||
#include "network/network.h"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @brief MTR Next Train application
|
||||
*
|
||||
* Provides multi-page navigation for:
|
||||
* 1. Line selection - choose MTR line
|
||||
* 2. Station selection - choose station within selected line
|
||||
* 3. Arrival display - show real-time train arrival information
|
||||
*/
|
||||
class MtrApp : public UIApp {
|
||||
public:
|
||||
MtrApp();
|
||||
virtual ~MtrApp() = default;
|
||||
|
||||
esp_err_t init(lv_obj_t* container) override;
|
||||
esp_err_t deinit(void) override;
|
||||
std::string get_name(void) const override;
|
||||
bool on_back_button_pressed(void) override;
|
||||
void handle_event(uint32_t event_type, void* event_data) override;
|
||||
|
||||
// Set network handler (must be called before using app)
|
||||
void set_network_handler(NetworkHandler* handler) { _network_handler = handler; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<MTRNextTrainHandler> _mtr_handler;
|
||||
std::unique_ptr<PageStack> _page_stack;
|
||||
NetworkHandler* _network_handler = nullptr;
|
||||
|
||||
// Current selection state
|
||||
std::string _selected_line_code;
|
||||
std::string _selected_station_code;
|
||||
LineInfo* _selected_line_info = nullptr;
|
||||
std::vector<LineInfo> _all_lines;
|
||||
|
||||
// Page builders
|
||||
void build_line_selection_page(lv_obj_t* page_container);
|
||||
void build_station_selection_page(lv_obj_t* page_container);
|
||||
void build_arrival_page(lv_obj_t* page_container);
|
||||
|
||||
// Event handlers
|
||||
static void line_button_event_cb(lv_event_t* e);
|
||||
static void station_button_event_cb(lv_event_t* e);
|
||||
static void refresh_button_event_cb(lv_event_t* e);
|
||||
|
||||
// Helper functions
|
||||
void load_arrival_data(lv_obj_t* page_container);
|
||||
uint32_t parse_color_hex(const char* hex_str);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief AppDescriptor for MtrApp
|
||||
*/
|
||||
class MtrAppDescriptor : public AppDescriptor {
|
||||
public:
|
||||
MtrAppDescriptor();
|
||||
void draw_icon(lv_obj_t* parent) override;
|
||||
|
||||
private:
|
||||
static MtrApp* _app_instance;
|
||||
};
|
||||
53
main/ui/apps/registry.h
Normal file
53
main/ui/apps/registry.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/apps/app.h"
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include "esp_log.h"
|
||||
#include <memory>
|
||||
|
||||
class AppRegistry {
|
||||
public:
|
||||
static AppRegistry& instance() {
|
||||
static AppRegistry registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
void register_app(std::unique_ptr<AppDescriptor> app_descriptor) {
|
||||
if (app_descriptors_.find(app_descriptor->get_name()) != app_descriptors_.end()) {
|
||||
// App already registered
|
||||
ESP_LOGW("AppRegistry", "App '%s' is already registered", app_descriptor->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
app_descriptors_.emplace(app_descriptor->get_name(), std::move(app_descriptor));
|
||||
}
|
||||
|
||||
size_t size() const {
|
||||
return app_descriptors_.size();
|
||||
}
|
||||
|
||||
// iterators to access registered apps
|
||||
auto begin() { return app_descriptors_.begin(); }
|
||||
auto begin() const { return app_descriptors_.begin(); }
|
||||
auto end() { return app_descriptors_.end(); }
|
||||
auto end() const { return app_descriptors_.end(); }
|
||||
|
||||
// [] operator to get app by name
|
||||
AppDescriptor* operator[](const std::string& name) {
|
||||
auto it = app_descriptors_.find(name);
|
||||
if (it != app_descriptors_.end()) {
|
||||
return it->second.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<std::string, std::unique_ptr<AppDescriptor>> app_descriptors_ = {};
|
||||
|
||||
AppRegistry() = default;
|
||||
// Disable copy and move semantics
|
||||
AppRegistry(const AppRegistry&) = delete;
|
||||
AppRegistry& operator=(const AppRegistry&) = delete;
|
||||
AppRegistry(AppRegistry&&) = delete;
|
||||
AppRegistry& operator=(AppRegistry&&) = delete;
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
#include "apps/shutdown_app.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "ShutdownApp"
|
||||
|
||||
ShutdownApp::ShutdownApp(std::string message)
|
||||
: _message(message.empty() ? "System Shutting Down..." : message) { }
|
||||
|
||||
esp_err_t ShutdownApp::init(lv_obj_t* container) {
|
||||
if (!container) {
|
||||
ESP_LOGE(TAG, "Container is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
_container = container;
|
||||
ESP_LOGI(TAG, "Initializing shutdown app with message: %s", _message.c_str());
|
||||
|
||||
// Main message label
|
||||
_label_message = lv_label_create(_container);
|
||||
lv_label_set_text(_label_message, _message.c_str());
|
||||
lv_obj_set_style_text_color(_label_message, lv_color_white(), 0);
|
||||
lv_obj_align(_label_message, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
// Optional: Add spinner animation
|
||||
lv_obj_t* spinner = lv_spinner_create(_container);
|
||||
lv_obj_set_size(spinner, 80, 80);
|
||||
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 80);
|
||||
lv_obj_set_style_arc_color(spinner, lv_color_white(), LV_PART_INDICATOR);
|
||||
|
||||
ESP_LOGI(TAG, "Shutdown app initialized successfully");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ShutdownApp::deinit(void) {
|
||||
ESP_LOGI(TAG, "Deinitializing shutdown app");
|
||||
_label_message = nullptr;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
std::string ShutdownApp::get_name(void) const {
|
||||
return "Shutdown";
|
||||
}
|
||||
|
||||
// ShutdownAppDescriptor implementation
|
||||
ShutdownApp* ShutdownAppDescriptor::_app_instance = nullptr;
|
||||
|
||||
ShutdownAppDescriptor::ShutdownAppDescriptor()
|
||||
: AppDescriptor("Shutdown", nullptr) {
|
||||
// Create singleton app instance with default message
|
||||
if (!_app_instance) {
|
||||
_app_instance = new ShutdownApp();
|
||||
}
|
||||
|
||||
// it's only used during system shutdown, not as a user-launchable app
|
||||
}
|
||||
|
||||
void ShutdownAppDescriptor::draw_icon(lv_obj_t* parent) {
|
||||
// Create a simple icon (not normally shown in nav bar)
|
||||
lv_obj_t* icon_label = lv_label_create(parent);
|
||||
lv_label_set_text(icon_label, LV_SYMBOL_POWER "\nShutdown");
|
||||
lv_obj_set_style_text_color(icon_label, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_align(icon_label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_center(icon_label);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/ui_app.h"
|
||||
#include "ui/app_registry.h"
|
||||
|
||||
/**
|
||||
* @brief Shutdown application - displays shutdown message
|
||||
*
|
||||
* Shown when the system is about to enter deep sleep or power off.
|
||||
* Displays a message and optionally a spinner animation.
|
||||
*/
|
||||
class ShutdownApp : public UIApp {
|
||||
public:
|
||||
ShutdownApp(std::string message = "");
|
||||
virtual ~ShutdownApp() = default;
|
||||
|
||||
esp_err_t init(lv_obj_t* container) override;
|
||||
esp_err_t deinit(void) override;
|
||||
std::string get_name(void) const override;
|
||||
|
||||
private:
|
||||
std::string _message;
|
||||
lv_obj_t* _label_message = nullptr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief AppDescriptor for ShutdownApp
|
||||
*
|
||||
* Note: Shutdown app is typically not shown in the navigation bar
|
||||
* as it's only used during system shutdown.
|
||||
*/
|
||||
class ShutdownAppDescriptor : public AppDescriptor {
|
||||
public:
|
||||
ShutdownAppDescriptor();
|
||||
void draw_icon(lv_obj_t* parent) override;
|
||||
|
||||
private:
|
||||
static ShutdownApp* _app_instance;
|
||||
};
|
||||
4
main/ui/events.cpp
Normal file
4
main/ui/events.cpp
Normal file
@@ -0,0 +1,4 @@
|
||||
#include "events.h"
|
||||
|
||||
// Define the event base
|
||||
ESP_EVENT_DEFINE_BASE(UI_EVENT_BASE);
|
||||
15
main/ui/events.h
Normal file
15
main/ui/events.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
ESP_EVENT_DECLARE_BASE(UI_EVENT_BASE);
|
||||
|
||||
struct KeyboardEventData {
|
||||
lv_obj_t* textarea; ///< The textarea that triggered the keyboard event, nullptr if not applicable or for hide event
|
||||
};
|
||||
|
||||
enum EventId {
|
||||
UI_EVENT_KEYBOARD_SHOWN = 1, ///< Event ID for keyboard shown event
|
||||
UI_EVENT_KEYBOARD_HIDDEN = 2 ///< Event ID for keyboard hidden event
|
||||
};
|
||||
159
main/ui/interaction_handler.cpp
Normal file
159
main/ui/interaction_handler.cpp
Normal file
@@ -0,0 +1,159 @@
|
||||
#include "ui/interaction_handler.h"
|
||||
#include "ui/events.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "InteractionHandler"
|
||||
|
||||
InteractionHandler::~InteractionHandler() {
|
||||
esp_err_t err = deinit();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Error during InteractionHandler deinit: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t InteractionHandler::init(lv_obj_t* app_container) {
|
||||
if (!app_container) {
|
||||
ESP_LOGE(TAG, "Invalid argument: app_container is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
app_container_ = app_container;
|
||||
keyboard_ = lv_keyboard_create(app_container_);
|
||||
lv_obj_add_flag(keyboard_, LV_OBJ_FLAG_HIDDEN); // start hidden
|
||||
lv_obj_add_event_cb(
|
||||
keyboard_,
|
||||
[](lv_event_t* e) {
|
||||
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
|
||||
handler->on_keyboard_event_(e);
|
||||
}
|
||||
, LV_EVENT_ALL, this);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t InteractionHandler::deinit(void) {
|
||||
if (keyboard_) {
|
||||
lv_obj_del(keyboard_);
|
||||
keyboard_ = nullptr;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t InteractionHandler::register_text_area_keyboard_support(lv_obj_t* text_area) {
|
||||
if (!text_area) {
|
||||
ESP_LOGE(TAG, "Invalid argument: text_area is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
lv_obj_add_event_cb(
|
||||
text_area,
|
||||
[](lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code != LV_EVENT_FOCUSED) {
|
||||
return;
|
||||
}
|
||||
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
|
||||
|
||||
esp_err_t err = handler->show_keyboard_for_textarea_(static_cast<lv_obj_t*>(lv_event_get_target(e)));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to show keyboard: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
, LV_EVENT_FOCUSED, this);
|
||||
|
||||
lv_obj_add_event_cb(
|
||||
text_area,
|
||||
[](lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code != LV_EVENT_DEFOCUSED) {
|
||||
return;
|
||||
}
|
||||
InteractionHandler* handler = static_cast<InteractionHandler*>(lv_event_get_user_data(e));
|
||||
|
||||
esp_err_t err = handler->hide_keyboard_();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to hide keyboard: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
, LV_EVENT_DEFOCUSED, this);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
//
|
||||
// Private methods
|
||||
//
|
||||
|
||||
void InteractionHandler::on_keyboard_event_(lv_event_t* e) {
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) {
|
||||
// Keyboard is cancelled
|
||||
esp_err_t err = hide_keyboard_();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to hide keyboard: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
if (focused_textarea_) {
|
||||
lv_obj_clear_state(focused_textarea_, LV_STATE_FOCUSED);
|
||||
lv_keyboard_set_textarea(keyboard_, nullptr);
|
||||
focused_textarea_ = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t InteractionHandler::show_keyboard_for_textarea_(lv_obj_t* textarea) {
|
||||
if (!keyboard_ || !textarea) {
|
||||
ESP_LOGE(TAG, "Invalid state or argument in show_keyboard_for_textarea_");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
focused_textarea_ = textarea;
|
||||
lv_keyboard_set_textarea(keyboard_, textarea);
|
||||
lv_obj_clear_flag(keyboard_, LV_OBJ_FLAG_HIDDEN);
|
||||
// emit keyboard shown event
|
||||
KeyboardEventData event_data = {
|
||||
.textarea = textarea
|
||||
};
|
||||
esp_err_t err = esp_event_post_to(
|
||||
NULL,
|
||||
UI_EVENT_BASE,
|
||||
UI_EVENT_KEYBOARD_SHOWN,
|
||||
&event_data,
|
||||
sizeof(event_data),
|
||||
portMAX_DELAY
|
||||
);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to post keyboard shown event: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t InteractionHandler::hide_keyboard_(void) {
|
||||
if (!keyboard_) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
lv_obj_add_flag(keyboard_, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// emit keyboard hidden event
|
||||
KeyboardEventData event_data = {
|
||||
.textarea = nullptr
|
||||
};
|
||||
|
||||
esp_err_t err = esp_event_post_to(
|
||||
NULL,
|
||||
UI_EVENT_BASE,
|
||||
UI_EVENT_KEYBOARD_HIDDEN,
|
||||
&event_data,
|
||||
sizeof(event_data),
|
||||
portMAX_DELAY
|
||||
);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to post keyboard hidden event: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
70
main/ui/interaction_handler.h
Normal file
70
main/ui/interaction_handler.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "lvgl.h"
|
||||
#include "ui/events.h"
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @brief Interaction Handler - manages user interactions
|
||||
*
|
||||
* This class is responsible for handling user inputs
|
||||
* such as touch events, button presses, and gestures.
|
||||
* It routes these interactions to the appropriate UI components
|
||||
* or apps based on the current context. And it also handles the respective UI widgets.
|
||||
*
|
||||
* For example, it manages:
|
||||
* Textarea focus and display of the on-screen keyboard
|
||||
*/
|
||||
class InteractionHandler {
|
||||
public:
|
||||
|
||||
InteractionHandler() = default;
|
||||
~InteractionHandler();
|
||||
|
||||
/**
|
||||
* @brief Initialize the Interaction Handler
|
||||
*
|
||||
* Sets up necessary event listeners and state.
|
||||
*
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t init(lv_obj_t* app_container);
|
||||
|
||||
/**
|
||||
* @brief Deinitialize the Interaction Handler
|
||||
*
|
||||
* Cleans up resources and event listeners.
|
||||
*
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t deinit(void);
|
||||
|
||||
/**
|
||||
* @brief Add keyboard support to a textarea widget
|
||||
*
|
||||
* @param text_area Pointer to the textarea lvgl object
|
||||
* @return esp_err_t ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t register_text_area_keyboard_support(lv_obj_t* text_area);
|
||||
|
||||
private:
|
||||
|
||||
// Event handler for keyboard show/hide events
|
||||
// It should be registered with event callbacks of the keyboard object
|
||||
void on_keyboard_event_(lv_event_t* e);
|
||||
|
||||
esp_err_t show_keyboard_for_textarea_(lv_obj_t* textarea);
|
||||
esp_err_t hide_keyboard_(void);
|
||||
|
||||
// Pointers to key UI objects, owned by UIHandler
|
||||
lv_obj_t* app_container_ = nullptr;
|
||||
// owned keyboard object
|
||||
lv_obj_t* keyboard_ = nullptr;
|
||||
// Currently focused textarea, reference only
|
||||
lv_obj_t* focused_textarea_ = nullptr;
|
||||
|
||||
InteractionHandler(const InteractionHandler&) = delete;
|
||||
InteractionHandler& operator=(const InteractionHandler&) = delete;
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
#include "page_stack.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char* TAG = "PageStack";
|
||||
|
||||
PageStack::PageStack(lv_obj_t* parent_container)
|
||||
: parent_container_(parent_container) {
|
||||
if (!parent_container_) {
|
||||
ESP_LOGE(TAG, "Parent container is null");
|
||||
}
|
||||
}
|
||||
|
||||
PageStack::~PageStack() {
|
||||
clear();
|
||||
}
|
||||
|
||||
lv_obj_t* PageStack::create_page_container() {
|
||||
lv_obj_t* page = lv_obj_create(parent_container_);
|
||||
|
||||
// Fill parent container
|
||||
lv_obj_set_size(page, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_pos(page, 0, 0);
|
||||
|
||||
// Remove padding and scrollbars
|
||||
lv_obj_set_style_pad_all(page, 0, 0);
|
||||
lv_obj_set_scrollbar_mode(page, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
// White background
|
||||
lv_obj_set_style_bg_color(page, lv_color_white(), 0);
|
||||
lv_obj_set_style_bg_opa(page, LV_OPA_COVER, 0);
|
||||
|
||||
// Remove border
|
||||
lv_obj_set_style_border_width(page, 0, 0);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
lv_obj_t* PageStack::push(PageBuilder builder, PageCleanup cleanup) {
|
||||
if (!parent_container_) {
|
||||
ESP_LOGE(TAG, "Cannot push page: parent container is null");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!builder) {
|
||||
ESP_LOGE(TAG, "Cannot push page: builder is null");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Hide current page if any
|
||||
if (!pages_.empty()) {
|
||||
lv_obj_add_flag(pages_.back().container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// Create new page container
|
||||
lv_obj_t* page = create_page_container();
|
||||
|
||||
// Build page content
|
||||
builder(page);
|
||||
|
||||
// Add to stack
|
||||
pages_.push_back({page, cleanup});
|
||||
|
||||
ESP_LOGD(TAG, "Pushed page (depth: %d)", pages_.size());
|
||||
return page;
|
||||
}
|
||||
|
||||
bool PageStack::pop() {
|
||||
if (pages_.empty()) {
|
||||
ESP_LOGW(TAG, "Cannot pop: stack is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get and remove current page
|
||||
Page current = pages_.back();
|
||||
pages_.pop_back();
|
||||
|
||||
// Call cleanup callback if provided
|
||||
if (current.cleanup) {
|
||||
current.cleanup(current.container);
|
||||
}
|
||||
|
||||
// Delete page container
|
||||
lv_obj_del(current.container);
|
||||
|
||||
// Show previous page if any
|
||||
if (!pages_.empty()) {
|
||||
lv_obj_clear_flag(pages_.back().container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Popped page (depth: %d)", pages_.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void PageStack::clear() {
|
||||
ESP_LOGD(TAG, "Clearing all pages (depth: %d)", pages_.size());
|
||||
|
||||
// Pop all pages (calls cleanup callbacks)
|
||||
while (!pages_.empty()) {
|
||||
Page current = pages_.back();
|
||||
pages_.pop_back();
|
||||
|
||||
if (current.cleanup) {
|
||||
current.cleanup(current.container);
|
||||
}
|
||||
|
||||
lv_obj_del(current.container);
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t* PageStack::current_page() const {
|
||||
if (pages_.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
return pages_.back().container;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
/**
|
||||
* @brief Reusable page stack for multi-page navigation within LVGL apps
|
||||
*
|
||||
* Manages a stack of LVGL containers, allowing apps to push/pop pages
|
||||
* and implement hierarchical navigation. Thread-safe for use with LVGL.
|
||||
*/
|
||||
class PageStack {
|
||||
public:
|
||||
/**
|
||||
* @brief Page builder callback
|
||||
* @param page_container The LVGL container to build the page in
|
||||
*/
|
||||
using PageBuilder = std::function<void(lv_obj_t* page_container)>;
|
||||
|
||||
/**
|
||||
* @brief Page cleanup callback
|
||||
* @param page_container The LVGL container being destroyed
|
||||
*/
|
||||
using PageCleanup = std::function<void(lv_obj_t* page_container)>;
|
||||
|
||||
/**
|
||||
* @brief Construct page stack with parent container
|
||||
* @param parent_container Parent LVGL container for pages
|
||||
*/
|
||||
explicit PageStack(lv_obj_t* parent_container);
|
||||
|
||||
/**
|
||||
* @brief Destructor - clears all pages
|
||||
*/
|
||||
~PageStack();
|
||||
|
||||
/**
|
||||
* @brief Push a new page onto the stack
|
||||
* @param builder Function to build page content
|
||||
* @param cleanup Optional cleanup function called when page is popped
|
||||
* @return The created page container
|
||||
*/
|
||||
lv_obj_t* push(PageBuilder builder, PageCleanup cleanup = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Pop the current page and return to previous
|
||||
* @return true if page was popped, false if stack is empty
|
||||
*/
|
||||
bool pop();
|
||||
|
||||
/**
|
||||
* @brief Clear all pages from the stack
|
||||
*/
|
||||
void clear();
|
||||
|
||||
/**
|
||||
* @brief Get the current (top) page container
|
||||
* @return Current page or nullptr if stack is empty
|
||||
*/
|
||||
lv_obj_t* current_page() const;
|
||||
|
||||
/**
|
||||
* @brief Get the number of pages in the stack
|
||||
*/
|
||||
size_t depth() const { return pages_.size(); }
|
||||
|
||||
/**
|
||||
* @brief Check if stack is empty
|
||||
*/
|
||||
bool empty() const { return pages_.empty(); }
|
||||
|
||||
private:
|
||||
struct Page {
|
||||
lv_obj_t* container;
|
||||
PageCleanup cleanup;
|
||||
};
|
||||
|
||||
lv_obj_t* parent_container_;
|
||||
std::vector<Page> pages_;
|
||||
|
||||
/**
|
||||
* @brief Create a page container
|
||||
*/
|
||||
lv_obj_t* create_page_container();
|
||||
};
|
||||
@@ -1,123 +1,93 @@
|
||||
#include "ui/root_layout.h"
|
||||
#include "ui/ui_handler.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "ui/events.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_event.h"
|
||||
|
||||
#define TAG "RootLayout"
|
||||
|
||||
// Display dimensions
|
||||
#define DISPLAY_WIDTH 800
|
||||
#define DISPLAY_HEIGHT 480
|
||||
|
||||
// Layout dimensions
|
||||
#define HEADER_HEIGHT 40
|
||||
#define NAV_BAR_HEIGHT 50
|
||||
#define APP_CONTAINER_HEIGHT (DISPLAY_HEIGHT - HEADER_HEIGHT - NAV_BAR_HEIGHT)
|
||||
|
||||
// forward-declare local event callback
|
||||
static void on_home_button_clicked(lv_event_t* event);
|
||||
|
||||
RootLayout::RootLayout(UIHandler* ui_handler)
|
||||
: _ui_handler(ui_handler) { }
|
||||
|
||||
esp_err_t RootLayout::init(lv_obj_t* parent) {
|
||||
if (!parent) {
|
||||
ESP_LOGE(TAG, "Parent object is null");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Initializing RootLayout");
|
||||
|
||||
if (create_layout(parent) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to create layout");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "RootLayout initialized successfully");
|
||||
return ESP_OK;
|
||||
RootLayout::~RootLayout() {
|
||||
deinit();
|
||||
}
|
||||
|
||||
esp_err_t RootLayout::deinit(void) {
|
||||
ESP_LOGI(TAG, "Deinitializing RootLayout");
|
||||
esp_err_t RootLayout::init(lv_obj_t* parent, UIHandler* ui_handler) {
|
||||
|
||||
// LVGL will handle cleanup when parent is destroyed
|
||||
_header = nullptr;
|
||||
_header_label = nullptr;
|
||||
_app_container = nullptr;
|
||||
_nav_bar = nullptr;
|
||||
_back_button = nullptr;
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t RootLayout::create_layout(lv_obj_t* parent) {
|
||||
// Configure parent as flexbox column layout
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_set_style_pad_all(parent, 0, 0);
|
||||
lv_obj_set_style_pad_gap(parent, 0, 0);
|
||||
|
||||
//
|
||||
// Create header (top, fixed height)
|
||||
_header = lv_obj_create(parent);
|
||||
lv_obj_set_width(_header, lv_pct(100));
|
||||
lv_obj_set_height(_header, HEADER_HEIGHT);
|
||||
lv_obj_set_style_bg_color(_header, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_style_border_width(_header, 0, 0);
|
||||
lv_obj_set_style_border_color(_header, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_border_width(_header, 1, LV_BORDER_SIDE_BOTTOM);
|
||||
lv_obj_set_style_pad_all(_header, 0, 0);
|
||||
lv_obj_set_style_radius(_header, 0, 0);
|
||||
|
||||
_header_label = lv_label_create(_header);
|
||||
lv_label_set_text(_header_label, "App");
|
||||
lv_obj_set_style_text_color(_header_label, lv_color_black(), 0);
|
||||
lv_obj_align(_header_label, LV_ALIGN_LEFT_MID, 10, 0);
|
||||
|
||||
// Create app container (middle, flexible - grows to fill available space)
|
||||
_app_container = lv_obj_create(parent);
|
||||
lv_obj_set_width(_app_container, lv_pct(100));
|
||||
lv_obj_set_flex_grow(_app_container, 1);
|
||||
lv_obj_set_style_bg_color(_app_container, lv_color_white(), 0);
|
||||
lv_obj_set_style_border_width(_app_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(_app_container, 0, 0);
|
||||
lv_obj_set_style_radius(_app_container, 0, 0);
|
||||
header_obj_ = lv_obj_create(parent);
|
||||
lv_obj_set_width(header_obj_, lv_pct(100));
|
||||
lv_obj_set_height(header_obj_, HEADER_HEIGHT);
|
||||
lv_obj_set_style_bg_color(header_obj_, lv_color_white(), 0);
|
||||
lv_obj_set_style_border_width(header_obj_, 0, 0);
|
||||
lv_obj_set_style_border_color(header_obj_, lv_color_black(), 0);
|
||||
lv_obj_set_style_border_width(header_obj_, 1, LV_BORDER_SIDE_BOTTOM);
|
||||
lv_obj_set_style_pad_all(header_obj_, 0, 0);
|
||||
lv_obj_set_style_radius(header_obj_, 0, 0);
|
||||
//
|
||||
header_label_ = lv_label_create(header_obj_);
|
||||
lv_label_set_text(header_label_, "App");
|
||||
lv_obj_set_style_text_color(header_label_, lv_color_black(), 0);
|
||||
lv_obj_align(header_label_, LV_ALIGN_LEFT_MID, 10, 0);
|
||||
//
|
||||
// Create app container (middle, flexible height)
|
||||
app_container_ = lv_obj_create(parent);
|
||||
lv_obj_set_width(app_container_, lv_pct(100));
|
||||
lv_obj_set_flex_grow(app_container_, 1);
|
||||
lv_obj_set_style_bg_color(app_container_, lv_color_white(), 0);
|
||||
lv_obj_set_style_border_width(app_container_, 0, 0);
|
||||
lv_obj_set_style_pad_all(app_container_, 0, 0);
|
||||
lv_obj_set_style_radius(app_container_, 0, 0);
|
||||
|
||||
//
|
||||
// Create navigation bar (bottom, fixed height)
|
||||
_nav_bar = lv_obj_create(parent);
|
||||
lv_obj_set_width(_nav_bar, lv_pct(100));
|
||||
lv_obj_set_height(_nav_bar, NAV_BAR_HEIGHT);
|
||||
lv_obj_set_style_bg_color(_nav_bar, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_style_border_color(_nav_bar, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_border_width(_nav_bar, 1, LV_BORDER_SIDE_TOP);
|
||||
lv_obj_set_style_pad_all(_nav_bar, 5, 0);
|
||||
lv_obj_set_style_radius(_nav_bar, 0, 0);
|
||||
nav_bar_obj_ = lv_obj_create(parent);
|
||||
lv_obj_set_width(nav_bar_obj_, lv_pct(100));
|
||||
lv_obj_set_height(nav_bar_obj_, NAV_BAR_HEIGHT);
|
||||
lv_obj_set_style_bg_color(nav_bar_obj_, lv_color_white(), 0);
|
||||
lv_obj_set_style_border_color(nav_bar_obj_, lv_color_black(), 0);
|
||||
lv_obj_set_style_border_width(nav_bar_obj_, 1, LV_BORDER_SIDE_TOP);
|
||||
lv_obj_set_style_pad_all(nav_bar_obj_, 5, 0);
|
||||
lv_obj_set_style_radius(nav_bar_obj_, 0, 0);
|
||||
|
||||
// Configure nav bar as flexbox row layout with space-between
|
||||
lv_obj_set_flex_flow(_nav_bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(_nav_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
// Create back button (aligned to start by flex layout)
|
||||
_back_button = lv_btn_create(_nav_bar);
|
||||
lv_obj_set_size(_back_button, 60, NAV_BAR_HEIGHT - 10);
|
||||
lv_obj_set_style_bg_color(_back_button, lv_color_hex(0x555555), 0);
|
||||
lv_obj_add_event_cb(_back_button, on_back_button_clicked, LV_EVENT_CLICKED, _ui_handler);
|
||||
lv_obj_add_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Add back arrow label
|
||||
lv_obj_t* back_label = lv_label_create(_back_button);
|
||||
back_button_ = lv_btn_create(nav_bar_obj_);
|
||||
lv_obj_set_size(back_button_, 60, NAV_BAR_HEIGHT - 10);
|
||||
lv_obj_set_style_bg_color(back_button_, lv_color_white(), 0);
|
||||
lv_obj_add_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_t* back_label = lv_label_create(back_button_);
|
||||
lv_label_set_text(back_label, LV_SYMBOL_LEFT);
|
||||
lv_obj_set_style_text_color(back_label, lv_color_black(), 0);
|
||||
lv_obj_align(back_label, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
// Create home button (aligned to end by flex layout)
|
||||
lv_obj_t* home_button = lv_btn_create(_nav_bar);
|
||||
lv_obj_set_size(home_button, 60, NAV_BAR_HEIGHT - 10);
|
||||
lv_obj_set_style_bg_color(home_button, lv_color_hex(0x555555), 0);
|
||||
lv_obj_t* home_label = lv_label_create(home_button);
|
||||
home_button_ = lv_btn_create(nav_bar_obj_);
|
||||
lv_obj_set_size(home_button_, 60, NAV_BAR_HEIGHT - 10);
|
||||
lv_obj_set_style_bg_color(home_button_, lv_color_white(), 0);
|
||||
lv_obj_t* home_label = lv_label_create(home_button_);
|
||||
lv_label_set_text(home_label, LV_SYMBOL_HOME);
|
||||
lv_obj_set_style_text_color(home_label, lv_color_white(), 0);
|
||||
lv_obj_set_style_text_color(home_label, lv_color_black(), 0);
|
||||
lv_obj_align(home_label, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_add_event_cb(home_button, on_home_button_clicked, LV_EVENT_CLICKED, _ui_handler);
|
||||
|
||||
// Register keyboard event handler
|
||||
esp_err_t err = esp_event_handler_instance_register(
|
||||
UI_EVENT_BASE,
|
||||
ESP_EVENT_ANY_ID,
|
||||
[](void* handler_args, esp_event_base_t base, int32_t id, void* event_data) {
|
||||
RootLayout* root_layout = static_cast<RootLayout*>(handler_args);
|
||||
root_layout->on_keyboard_event_(handler_args, base, id, event_data);
|
||||
},
|
||||
this,
|
||||
&keyboard_event_handler_instance_
|
||||
);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to register keyboard event handler: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Layout created with flexible design: Header=%d, NavBar=%d",
|
||||
HEADER_HEIGHT, NAV_BAR_HEIGHT);
|
||||
@@ -125,140 +95,155 @@ esp_err_t RootLayout::create_layout(lv_obj_t* parent) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void RootLayout::update_header(std::string app_name) {
|
||||
if (!_header_label) {
|
||||
return;
|
||||
esp_err_t RootLayout::deinit(void) {
|
||||
// Unregister keyboard event handler
|
||||
if (keyboard_event_handler_instance_) {
|
||||
esp_event_handler_instance_unregister(
|
||||
UI_EVENT_BASE,
|
||||
ESP_EVENT_ANY_ID,
|
||||
keyboard_event_handler_instance_
|
||||
);
|
||||
keyboard_event_handler_instance_ = nullptr;
|
||||
}
|
||||
|
||||
if (app_name.empty() == false) {
|
||||
lv_label_set_text(_header_label, app_name.c_str());
|
||||
header_obj_ = nullptr;
|
||||
header_label_ = nullptr;
|
||||
//
|
||||
app_container_ = nullptr;
|
||||
//
|
||||
nav_bar_obj_ = nullptr;
|
||||
back_button_ = nullptr;
|
||||
home_button_ = nullptr;
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void RootLayout::hide_nav_bar(void) const {
|
||||
if (nav_bar_obj_) {
|
||||
lv_obj_add_flag(nav_bar_obj_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_label_set_text(_header_label, "App");
|
||||
ESP_LOGW(TAG, "Navigation bar not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t RootLayout::render_app_icons(void) {
|
||||
if (!_nav_bar) {
|
||||
ESP_LOGE(TAG, "Navigation bar not initialized");
|
||||
void RootLayout::show_nav_bar(void) const {
|
||||
if (nav_bar_obj_) {
|
||||
lv_obj_clear_flag(nav_bar_obj_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Navigation bar not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
void RootLayout::show_back_button(void) const {
|
||||
if (back_button_) {
|
||||
lv_obj_clear_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Back button not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
void RootLayout::show_home_button(void) const {
|
||||
if (home_button_) {
|
||||
lv_obj_clear_flag(home_button_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Home button not found in navigation bar");
|
||||
}
|
||||
}
|
||||
|
||||
void RootLayout::hide_back_button(void) const {
|
||||
if (back_button_) {
|
||||
lv_obj_add_flag(back_button_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Back button not initialized");
|
||||
}
|
||||
}
|
||||
|
||||
void RootLayout::hide_home_button(void) const {
|
||||
if (home_button_) {
|
||||
lv_obj_add_flag(home_button_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Home button not found in navigation bar");
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t RootLayout::register_back_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const {
|
||||
if (!back_button_) {
|
||||
ESP_LOGE(TAG, "Back button not initialized");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!callback) {
|
||||
ESP_LOGE(TAG, "Invalid argument: callback is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (out_event_dsc == nullptr) {
|
||||
ESP_LOGE(TAG, "Invalid argument: out_event_dsc is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_event_dsc = lv_obj_add_event_cb(back_button_, callback, LV_EVENT_CLICKED, user_data);
|
||||
|
||||
if (*out_event_dsc == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to register back button callback");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Clear existing app container content (icons are rendered in the app area)
|
||||
if (!_app_container) {
|
||||
ESP_LOGE(TAG, "App container not initialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
lv_obj_clean(_app_container);
|
||||
|
||||
// Get all registered apps from registry
|
||||
const auto& app_descriptors = AppRegistry::instance().get_app_descriptors();
|
||||
|
||||
if (app_descriptors.empty()) {
|
||||
ESP_LOGW(TAG, "No apps registered in AppRegistry");
|
||||
lv_obj_t* nav_label = lv_label_create(_nav_bar);
|
||||
lv_label_set_text(nav_label, "No apps available");
|
||||
lv_obj_set_style_text_color(nav_label, lv_color_white(), 0);
|
||||
lv_obj_align(nav_label, LV_ALIGN_CENTER, 0, 0);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Rendering %d app icons", (int)app_descriptors.size());
|
||||
|
||||
// Calculate icon spacing inside the app container
|
||||
int icon_count = app_descriptors.size();
|
||||
int icon_width = 96;
|
||||
int icon_height = 96;
|
||||
int icon_spacing = DISPLAY_WIDTH / (icon_count + 1);
|
||||
int x_offset = icon_spacing;
|
||||
int y_offset = (APP_CONTAINER_HEIGHT - icon_height) / 2;
|
||||
|
||||
// Render each app icon into the app container
|
||||
for (size_t i = 0; i < app_descriptors.size(); i++) {
|
||||
AppDescriptor* descriptor = app_descriptors[i];
|
||||
|
||||
lv_obj_t* icon_container = lv_obj_create(_app_container);
|
||||
lv_obj_set_size(icon_container, icon_width, icon_height);
|
||||
lv_obj_set_pos(icon_container, x_offset - icon_width / 2, y_offset);
|
||||
lv_obj_set_style_bg_opa(icon_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_pad_all(icon_container, 0, 0);
|
||||
// add a border for debugging
|
||||
lv_obj_set_style_border_color(icon_container, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_border_width(icon_container, 1, 0);
|
||||
|
||||
lv_obj_set_user_data(icon_container, descriptor);
|
||||
|
||||
descriptor->draw_icon(icon_container);
|
||||
|
||||
lv_obj_add_flag(icon_container, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(icon_container, on_app_icon_clicked, LV_EVENT_CLICKED, _ui_handler);
|
||||
|
||||
x_offset += icon_spacing;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void RootLayout::show_back_button(void) {
|
||||
if (_back_button) {
|
||||
lv_obj_clear_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
|
||||
esp_err_t RootLayout::register_home_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const {
|
||||
if (!home_button_) {
|
||||
ESP_LOGE(TAG, "Home button not found in navigation bar");
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (!callback) {
|
||||
ESP_LOGE(TAG, "Invalid argument: callback is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (out_event_dsc == nullptr) {
|
||||
ESP_LOGE(TAG, "Invalid argument: out_event_dsc is nullptr");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_event_dsc = lv_obj_add_event_cb(home_button_, callback, LV_EVENT_CLICKED, user_data);
|
||||
|
||||
if (*out_event_dsc == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to register home button callback");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void RootLayout::hide_back_button(void) {
|
||||
if (_back_button) {
|
||||
lv_obj_add_flag(_back_button, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
void RootLayout::on_app_icon_clicked(lv_event_t* event) {
|
||||
// Use the current target (the object the callback was attached to)
|
||||
// instead of the event target, which may be a child (like a label).
|
||||
lv_obj_t* icon_container = static_cast<lv_obj_t*>(lv_event_get_current_target(event));
|
||||
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
|
||||
AppDescriptor* descriptor = static_cast<AppDescriptor*>(lv_obj_get_user_data(icon_container));
|
||||
|
||||
if (!handler || !descriptor) {
|
||||
ESP_LOGE(TAG, "Invalid event data in app icon click");
|
||||
return;
|
||||
esp_err_t RootLayout::update_header(const std::string& title) const {
|
||||
if (!header_label_) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "App icon clicked: %s", descriptor->get_name().c_str());
|
||||
handler->switch_app(descriptor);
|
||||
}
|
||||
|
||||
void RootLayout::on_back_button_clicked(lv_event_t* event) {
|
||||
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
|
||||
|
||||
if (!handler) {
|
||||
ESP_LOGE(TAG, "Invalid handler in back button click");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the active app
|
||||
UIApp* active_app = handler->get_active_app();
|
||||
if (!active_app) {
|
||||
ESP_LOGW(TAG, "Back button pressed but no active app");
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the app handle the back button press
|
||||
bool handled = active_app->on_back_button_pressed();
|
||||
|
||||
if (handled) {
|
||||
ESP_LOGI(TAG, "Back button handled by app: %s", active_app->get_name());
|
||||
if (title.empty() == false) {
|
||||
lv_label_set_text(header_label_, title.c_str());
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Back button not handled by app, returning to main screen");
|
||||
handler->return_to_main_screen();
|
||||
lv_label_set_text(header_label_, "App");
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void on_home_button_clicked(lv_event_t* event) {
|
||||
UIHandler* handler = static_cast<UIHandler*>(lv_event_get_user_data(event));
|
||||
|
||||
if (!handler) {
|
||||
ESP_LOGE(TAG, "Invalid handler in home button click");
|
||||
void RootLayout::on_keyboard_event_(void* handler_args, esp_event_base_t base, int32_t id, void* event_data) {
|
||||
if (base != UI_EVENT_BASE) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler->return_to_main_screen();
|
||||
switch (id) {
|
||||
case UI_EVENT_KEYBOARD_SHOWN:
|
||||
hide_nav_bar();
|
||||
break;
|
||||
|
||||
case UI_EVENT_KEYBOARD_HIDDEN:
|
||||
show_nav_bar();
|
||||
break;
|
||||
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unknown keyboard event ID: %ld", id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_event.h"
|
||||
#include "lvgl.h"
|
||||
#include <string>
|
||||
|
||||
// Forward declaration
|
||||
// Forward declaration to avoid circular dependency
|
||||
class UIHandler;
|
||||
|
||||
/**
|
||||
* @brief Root Layout Manager - manages the main screen layout
|
||||
*
|
||||
* The RootLayout class is responsible for:
|
||||
* - Creating and managing the main screen structure (header, app container, nav bar)
|
||||
* - Rendering app icons from the AppRegistry
|
||||
* - Managing the back button
|
||||
* - Updating header content
|
||||
*/
|
||||
class RootLayout {
|
||||
public:
|
||||
/**
|
||||
* @brief Construct a new RootLayout object
|
||||
*
|
||||
* @param ui_handler Pointer to the UIHandler (for callbacks)
|
||||
*/
|
||||
RootLayout(UIHandler* ui_handler);
|
||||
RootLayout() = default;
|
||||
~RootLayout();
|
||||
|
||||
/**
|
||||
* @brief Initialize the layout
|
||||
* @brief Initialize the root layout within the given parent object
|
||||
*
|
||||
* Creates the main screen with header, app container, and navigation bar.
|
||||
* Sets up the header, app container, and navigation bar.
|
||||
*
|
||||
* @param parent Parent LVGL object to attach layout to
|
||||
* @param parent Parent LVGL object to contain the layout
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t init(lv_obj_t* parent);
|
||||
esp_err_t init(lv_obj_t* parent, UIHandler* ui_handler);
|
||||
|
||||
/**
|
||||
* @brief Deinitialize the layout
|
||||
* @brief Deinitialize the root layout
|
||||
*
|
||||
* Cleans up all layout widgets.
|
||||
* Cleans up references to layout components.
|
||||
*
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t deinit(void);
|
||||
|
||||
/**
|
||||
* @brief Render app icons in the navigation bar
|
||||
* @brief Show the back button in the navigation bar
|
||||
*/
|
||||
void show_back_button(void) const;
|
||||
|
||||
/**
|
||||
* @brief Hide the back button in the navigation bar
|
||||
*/
|
||||
void hide_back_button(void) const;
|
||||
|
||||
/**
|
||||
* @brief Show the home button in the navigation bar
|
||||
*/
|
||||
void show_home_button(void) const;
|
||||
|
||||
/**
|
||||
* @brief Hide the home button in the navigation bar
|
||||
*/
|
||||
void hide_home_button(void) const;
|
||||
|
||||
/**
|
||||
* @brief Show navigation bar
|
||||
*
|
||||
* Queries the AppRegistry for all registered apps and
|
||||
* renders their icons in the navigation bar. Also creates
|
||||
* the back button.
|
||||
*/
|
||||
void show_nav_bar(void) const;
|
||||
|
||||
/**
|
||||
* @brief Hide navigation bar
|
||||
*
|
||||
*/
|
||||
void hide_nav_bar(void) const;
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @brief Register a callback for back button presses
|
||||
*
|
||||
*
|
||||
* @param callback
|
||||
* @param user_data
|
||||
* @param out_event_dsc
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t render_app_icons(void);
|
||||
esp_err_t register_back_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const;
|
||||
|
||||
/**
|
||||
* @brief Update header with app name
|
||||
* @brief Register a callback for home button presses
|
||||
*
|
||||
* @param app_name Name to display in header (nullptr for default)
|
||||
* @param callback
|
||||
* @param user_data
|
||||
* @param out_event_dsc
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
void update_header(std::string app_name);
|
||||
esp_err_t register_home_button_callback(lv_event_cb_t callback, void* user_data, lv_event_dsc_t** out_event_dsc) const;
|
||||
|
||||
/**
|
||||
* @brief Show the back button
|
||||
*/
|
||||
void show_back_button(void);
|
||||
|
||||
/**
|
||||
* @brief Hide the back button
|
||||
*/
|
||||
void hide_back_button(void);
|
||||
|
||||
/**
|
||||
* @brief Get the header object
|
||||
* @brief Update the header title text
|
||||
*
|
||||
* @return lv_obj_t* pointer to the header container
|
||||
* @param title New title text
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
lv_obj_t* get_header(void) const {
|
||||
return _header;
|
||||
}
|
||||
esp_err_t update_header(const std::string& title) const;
|
||||
|
||||
/**
|
||||
* @brief Get the app container (where apps render)
|
||||
* @brief Get the app container object, which holds the active app's UI
|
||||
* Caller can add/remove app UI elements to/from this container.
|
||||
* Caller must not delete this object directly or edit its layout properties.
|
||||
*
|
||||
* @return lv_obj_t* pointer to the app container
|
||||
* @return lv_obj_t*
|
||||
*/
|
||||
lv_obj_t* get_app_container(void) const {
|
||||
return _app_container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the navigation bar object
|
||||
*
|
||||
* @return lv_obj_t* pointer to the navigation bar container
|
||||
*/
|
||||
lv_obj_t* get_nav_bar(void) const {
|
||||
return _nav_bar;
|
||||
lv_obj_t* get_app_container() const {
|
||||
return app_container_;
|
||||
}
|
||||
|
||||
private:
|
||||
UIHandler* _ui_handler = nullptr; ///< Reference to UIHandler for callbacks
|
||||
lv_obj_t* _header = nullptr; ///< Header area (top)
|
||||
lv_obj_t* _header_label = nullptr; ///< Header text label
|
||||
lv_obj_t* _app_container = nullptr; ///< Container for app widgets (middle)
|
||||
lv_obj_t* _nav_bar = nullptr; ///< Navigation bar (bottom)
|
||||
lv_obj_t* _back_button = nullptr; ///< Back button in navigation bar
|
||||
|
||||
/**
|
||||
* @brief Create the layout structure
|
||||
*
|
||||
* Sets up header, app container, and navigation bar with
|
||||
* appropriate dimensions and positioning.
|
||||
*
|
||||
* @param parent Parent object to attach layout to
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t create_layout(lv_obj_t* parent);
|
||||
// Event handler for keyboard show/hide events
|
||||
void on_keyboard_event_(void* handler_args, esp_event_base_t base, int32_t id, void* event_data);
|
||||
|
||||
/**
|
||||
* @brief Handle app icon click event
|
||||
*
|
||||
* Static callback for LVGL event handling.
|
||||
*
|
||||
* @param event LVGL event object
|
||||
*/
|
||||
static void on_app_icon_clicked(lv_event_t* event);
|
||||
// layout objects
|
||||
// header
|
||||
lv_obj_t* header_obj_ = nullptr; ///< Header area object
|
||||
lv_obj_t* header_label_ = nullptr; ///< Header title label
|
||||
// app container
|
||||
lv_obj_t* app_container_ = nullptr; ///< App container object
|
||||
// navigation bar
|
||||
lv_obj_t* nav_bar_obj_ = nullptr; ///< Navigation bar object
|
||||
lv_obj_t* back_button_ = nullptr; ///< Back button object
|
||||
lv_obj_t* home_button_ = nullptr; ///< Home button object
|
||||
|
||||
/**
|
||||
* @brief Handle back button click event
|
||||
*
|
||||
* Static callback for LVGL event handling.
|
||||
*
|
||||
* @param event LVGL event object
|
||||
*/
|
||||
static void on_back_button_clicked(lv_event_t* event);
|
||||
esp_event_handler_instance_t keyboard_event_handler_instance_ = nullptr; ///< Event handler instance for keyboard events
|
||||
};
|
||||
|
||||
@@ -1,208 +1,288 @@
|
||||
#include "ui/ui_handler.h"
|
||||
#include "ui/root_layout.h"
|
||||
#include "ui/app_registry.h"
|
||||
#include "esp_log.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
#define TAG "UIHandler"
|
||||
|
||||
// Display dimensions from constants.h
|
||||
#define DISPLAY_WIDTH 800
|
||||
#define DISPLAY_HEIGHT 480
|
||||
|
||||
// Layout dimensions
|
||||
#define HEADER_HEIGHT 40
|
||||
#define NAV_BAR_HEIGHT 50
|
||||
#define _APP_CONTAINERHEIGHT (DISPLAY_HEIGHT - HEADER_HEIGHT - NAV_BAR_HEIGHT)
|
||||
UIHandler::~UIHandler() {
|
||||
deinit();
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::init(void) {
|
||||
ESP_LOGI(TAG, "Initializing UIHandler");
|
||||
lv_obj_t* screen = lv_scr_act();
|
||||
esp_err_t ret = ESP_OK;
|
||||
|
||||
// Create main screen
|
||||
_main_screen = lv_obj_create(NULL);
|
||||
if (!_main_screen) {
|
||||
ESP_LOGE(TAG, "Failed to create main screen");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
lv_obj_set_style_bg_color(_main_screen, lv_color_black(), 0);
|
||||
lv_obj_set_size(_main_screen, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
||||
|
||||
// Create root layout
|
||||
_root_layout = new RootLayout(this);
|
||||
if (!_root_layout) {
|
||||
ESP_LOGE(TAG, "Failed to allocate RootLayout");
|
||||
return ESP_FAIL;
|
||||
// Create main screen layout
|
||||
ret = create_main_screen_(screen);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to create main screen layout");
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (_root_layout->init(_main_screen) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize root layout");
|
||||
delete _root_layout;
|
||||
_root_layout = nullptr;
|
||||
return ESP_FAIL;
|
||||
ret = interaction_handler_.init(root_layout_.get_app_container());
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize InteractionHandler");
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Render app icons from registry
|
||||
if (_root_layout->render_app_icons() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to render app icons");
|
||||
}
|
||||
// Show the main screen
|
||||
lv_scr_load(screen);
|
||||
|
||||
// Defer screen loading to prevent blocking during initialization
|
||||
// Use LVGL timer to load screen after allowing watchdog reset
|
||||
lv_timer_create([](lv_timer_t* timer) {
|
||||
lv_obj_t* screen = static_cast<lv_obj_t*>(lv_timer_get_user_data(timer));
|
||||
ESP_LOGI("UIHandler", "Loading main screen via timer");
|
||||
lv_screen_load(screen);
|
||||
lv_timer_del(timer);
|
||||
}, 100, _main_screen); // 100ms delay to allow watchdog reset
|
||||
|
||||
ESP_LOGI(TAG, "UIHandler initialized successfully");
|
||||
return ESP_OK;
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::deinit(void) {
|
||||
ESP_LOGI(TAG, "Deinitializing UIHandler");
|
||||
|
||||
// Deinit current app
|
||||
if (_active_app) {
|
||||
if (_active_app->deinit() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Error deinitializing active app: %s", _active_app->get_name());
|
||||
// Deinitialize current app if any
|
||||
if (active_descriptor_) {
|
||||
UIApp* app = active_descriptor_->get_app_instance();
|
||||
if (app) {
|
||||
esp_err_t ret = app->deinit();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("UIHandler", "Failed to deinitialize current app");
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
_active_app = nullptr;
|
||||
active_descriptor_ = nullptr;
|
||||
}
|
||||
|
||||
// Delete shutdown app if cached
|
||||
if (_shutdown_app) {
|
||||
delete _shutdown_app;
|
||||
_shutdown_app = nullptr;
|
||||
// Destroy main screen layout
|
||||
esp_err_t ret = destroy_main_screen_();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("UIHandler", "Failed to destroy main screen layout");
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Clean up root layout
|
||||
if (_root_layout) {
|
||||
_root_layout->deinit();
|
||||
delete _root_layout;
|
||||
_root_layout = nullptr;
|
||||
}
|
||||
|
||||
// Main screen will be cleaned up by LVGL
|
||||
_main_screen = nullptr;
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::switch_app(UIApp* app) {
|
||||
if (!app) {
|
||||
ESP_LOGE(TAG, "Cannot switch to null app");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
lv_obj_t* app_container = get_app_container();
|
||||
if (!app_container) {
|
||||
ESP_LOGE(TAG, "App container not initialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Switching to app: %s", app->get_name());
|
||||
|
||||
// Deinit current app
|
||||
if (_active_app) {
|
||||
if (_active_app->deinit() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Error deinitializing app: %s", _active_app->get_name());
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the app container
|
||||
lv_obj_clean(app_container);
|
||||
|
||||
// Initialize new app
|
||||
if (app->init(app_container) != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize app: %s", app->get_name());
|
||||
_active_app = nullptr;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
_active_app = app;
|
||||
|
||||
// Update header through RootLayout
|
||||
if (_root_layout) {
|
||||
_root_layout->update_header(_active_app->get_name());
|
||||
_root_layout->show_back_button();
|
||||
// Deinitialize interaction handler
|
||||
ret = interaction_handler_.deinit();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE("UIHandler", "Failed to deinitialize InteractionHandler");
|
||||
return ret;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::switch_app(AppDescriptor* app_descriptor) {
|
||||
esp_err_t UIHandler::switch_app(std::shared_ptr<AppDescriptor> app_descriptor) {
|
||||
if (!app_descriptor) {
|
||||
ESP_LOGE(TAG, "Cannot switch to null app descriptor");
|
||||
ESP_LOGE(TAG, "Invalid app descriptor");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
UIApp* app = app_descriptor->get_app_instance();
|
||||
if (!app) {
|
||||
ESP_LOGE(TAG, "App descriptor has null app instance");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
esp_err_t ret = ESP_OK;
|
||||
|
||||
// Deinitialize current app if any
|
||||
if (active_descriptor_) {
|
||||
UIApp* current_app = active_descriptor_->get_app_instance();
|
||||
if (current_app) {
|
||||
ret = current_app->deinit();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to deinitialize current app");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return switch_app(app);
|
||||
}
|
||||
|
||||
void UIHandler::route_event(uint32_t event_type, void* event_data) {
|
||||
if (_active_app) {
|
||||
_active_app->handle_event(event_type, event_data);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::show_shutdown_screen(std::string message) {
|
||||
ESP_LOGI(TAG, "Showing shutdown screen");
|
||||
|
||||
lv_obj_t* app_container = get_app_container();
|
||||
if (!app_container) {
|
||||
ESP_LOGE(TAG, "App container not initialized");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// Clear current app reference
|
||||
_active_app = nullptr;
|
||||
|
||||
// Clear the app container
|
||||
lv_obj_clean(app_container);
|
||||
|
||||
// Create shutdown message
|
||||
lv_obj_t* shutdown_label = lv_label_create(app_container);
|
||||
lv_label_set_text(shutdown_label, message.empty() ? "Shutting down..." : message.c_str());
|
||||
lv_obj_set_style_text_color(shutdown_label, lv_color_white(), 0);
|
||||
lv_obj_align(shutdown_label, LV_ALIGN_CENTER, 0, 0);
|
||||
|
||||
// Update header through RootLayout
|
||||
if (_root_layout) {
|
||||
_root_layout->update_header("System Shutdown");
|
||||
lv_obj_t* app_container = root_layout_.get_app_container();
|
||||
if (app_container) {
|
||||
lv_obj_clean(app_container);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "App container not available");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// Set the new app as active
|
||||
active_descriptor_ = app_descriptor;
|
||||
|
||||
// Initialize the new app
|
||||
UIApp* new_app = active_descriptor_->get_app_instance();
|
||||
if (!new_app) {
|
||||
ESP_LOGE(TAG, "App instance not available");
|
||||
active_descriptor_ = nullptr;
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ret = new_app->init(app_container);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize app: %s", new_app->get_name().c_str());
|
||||
active_descriptor_ = nullptr;
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Update header with app name
|
||||
ret = update_header_title(new_app->get_name());
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to update header title");
|
||||
}
|
||||
|
||||
// Show back button when in an app
|
||||
root_layout_.show_back_button();
|
||||
|
||||
ESP_LOGI(TAG, "Switched to app: %s", new_app->get_name().c_str());
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::show_shutdown_screen(const std::string& message) {
|
||||
// Deinitialize current app if any
|
||||
if (active_descriptor_) {
|
||||
UIApp* app = active_descriptor_->get_app_instance();
|
||||
if (app) {
|
||||
app->deinit();
|
||||
}
|
||||
active_descriptor_ = nullptr;
|
||||
}
|
||||
|
||||
// Clear the app container
|
||||
lv_obj_t* app_container = root_layout_.get_app_container();
|
||||
if (app_container) {
|
||||
lv_obj_clean(app_container);
|
||||
|
||||
// Create a simple shutdown message screen
|
||||
lv_obj_t* label = lv_label_create(app_container);
|
||||
if (message.empty()) {
|
||||
lv_label_set_text(label, "Shutting down...");
|
||||
} else {
|
||||
lv_label_set_text(label, message.c_str());
|
||||
}
|
||||
lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0);
|
||||
lv_obj_center(label);
|
||||
}
|
||||
|
||||
// Update header
|
||||
update_header_title("System");
|
||||
|
||||
// Hide navigation buttons
|
||||
root_layout_.hide_back_button();
|
||||
root_layout_.hide_home_button();
|
||||
|
||||
ESP_LOGI(TAG, "Showing shutdown screen: %s", message.c_str());
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::return_to_main_screen(void) {
|
||||
ESP_LOGI(TAG, "Returning to main screen");
|
||||
esp_err_t ret = ESP_OK;
|
||||
|
||||
// Deinit current app
|
||||
if (_active_app) {
|
||||
if (_active_app->deinit() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Error deinitializing app: %s", _active_app->get_name());
|
||||
// Deinitialize current app if any
|
||||
if (active_descriptor_) {
|
||||
UIApp* app = active_descriptor_->get_app_instance();
|
||||
if (app) {
|
||||
ret = app->deinit();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to deinitialize app");
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
_active_app = nullptr;
|
||||
active_descriptor_ = nullptr;
|
||||
}
|
||||
|
||||
// Clear the app container
|
||||
lv_obj_t* app_container = get_app_container();
|
||||
lv_obj_t* app_container = root_layout_.get_app_container();
|
||||
if (app_container) {
|
||||
lv_obj_clean(app_container);
|
||||
|
||||
// TODO: Display app launcher/home screen with app icons
|
||||
// For now, just show a placeholder message
|
||||
lv_obj_t* label = lv_label_create(app_container);
|
||||
lv_label_set_text(label, "Home Screen\n\nApp icons will go here");
|
||||
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_center(label);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "App container not available");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// Update header and hide back button through RootLayout
|
||||
if (_root_layout) {
|
||||
_root_layout->update_header("");
|
||||
_root_layout->hide_back_button();
|
||||
// Update header
|
||||
ret = update_header_title("Home");
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to update header title");
|
||||
}
|
||||
|
||||
// Hide back button on home screen
|
||||
root_layout_.hide_back_button();
|
||||
|
||||
ESP_LOGI(TAG, "Returned to main screen");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::update_header_title(const std::string& title) {
|
||||
return root_layout_.update_header(title);
|
||||
}
|
||||
|
||||
//
|
||||
// Private methods
|
||||
//
|
||||
|
||||
void UIHandler::on_back_button_pressed_(void) {
|
||||
|
||||
if (active_descriptor_) {
|
||||
UIApp* app = active_descriptor_->get_app_instance();
|
||||
if (app) {
|
||||
bool handled = app->on_back_button_pressed();
|
||||
if (!handled) {
|
||||
// App didn't handle it, return to main screen
|
||||
return_to_main_screen();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Back button pressed but no active app");
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::create_main_screen_(lv_obj_t* parent) {
|
||||
esp_err_t ret = ESP_OK;
|
||||
|
||||
// Initialize root layout
|
||||
ret = root_layout_.init(parent, this);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize RootLayout");
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Register back button callback
|
||||
lv_event_dsc_t* back_event_dsc = nullptr;
|
||||
ret = root_layout_.register_back_button_callback(
|
||||
[](lv_event_t* e) {
|
||||
UIHandler* ui_handler = static_cast<UIHandler*>(lv_event_get_user_data(e));
|
||||
ui_handler->on_back_button_pressed_();
|
||||
},
|
||||
this,
|
||||
&back_event_dsc
|
||||
);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to register back button callback");
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Register home button callback
|
||||
lv_event_dsc_t* home_event_dsc = nullptr;
|
||||
ret = root_layout_.register_home_button_callback(
|
||||
[](lv_event_t* e) {
|
||||
UIHandler* ui_handler = static_cast<UIHandler*>(lv_event_get_user_data(e));
|
||||
ui_handler->return_to_main_screen();
|
||||
},
|
||||
this,
|
||||
&home_event_dsc
|
||||
);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to register home button callback");
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Main screen layout created successfully");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t UIHandler::destroy_main_screen_(void) {
|
||||
esp_err_t ret = root_layout_.deinit();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to deinitialize RootLayout");
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Main screen layout destroyed successfully");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui_app.h"
|
||||
#include "app_registry.h"
|
||||
#include "root_layout.h"
|
||||
#include "esp_err.h"
|
||||
|
||||
// Forward declaration
|
||||
class RootLayout;
|
||||
#include "ui/apps/app.h"
|
||||
#include "ui/events.h"
|
||||
#include "ui/root_layout.h"
|
||||
#include "ui/interaction_handler.h"
|
||||
#include "lvgl.h"
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @brief UI Handler - manages app lifecycle and rendering
|
||||
@@ -20,6 +20,10 @@ class RootLayout;
|
||||
*/
|
||||
class UIHandler {
|
||||
public:
|
||||
|
||||
UIHandler() = default;
|
||||
~UIHandler();
|
||||
|
||||
/**
|
||||
* @brief Initialize the UI system with default layout
|
||||
*
|
||||
@@ -28,6 +32,10 @@ public:
|
||||
* - App container (middle)
|
||||
* - Navigation bar (bottom)
|
||||
*
|
||||
* And display the main screen.
|
||||
*
|
||||
* And initializes the InteractionHandler, callbacks, etc.
|
||||
*
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t init(void);
|
||||
@@ -42,45 +50,16 @@ public:
|
||||
esp_err_t deinit(void);
|
||||
|
||||
/**
|
||||
* @brief Switch to a new app
|
||||
* @brief Switch to a new app by its descriptor
|
||||
*
|
||||
* Deinitializes the current app (if any), initializes the new app,
|
||||
* and updates the display.
|
||||
* and updates the display. Holds shared ownership of the descriptor
|
||||
* to ensure the app remains valid while active.
|
||||
*
|
||||
* @param app Pointer to the new app to switch to
|
||||
* @param app_descriptor Shared pointer to the app descriptor
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t switch_app(UIApp* app);
|
||||
|
||||
/**
|
||||
* @brief Switch to an app by its descriptor
|
||||
*
|
||||
* Convenience method that extracts the UIApp from the descriptor
|
||||
* and calls switch_app().
|
||||
*
|
||||
* @param app_descriptor Pointer to the app descriptor
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t switch_app(AppDescriptor* app_descriptor);
|
||||
|
||||
/**
|
||||
* @brief Get the currently active app
|
||||
*
|
||||
* @return Pointer to the active UIApp, or nullptr if none
|
||||
*/
|
||||
UIApp* get_active_app(void) const {
|
||||
return _active_app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Route a system event to the active app
|
||||
*
|
||||
* If an app is active, this forwards the event to it.
|
||||
*
|
||||
* @param event_type Type/ID of the event
|
||||
* @param event_data Optional event data payload
|
||||
*/
|
||||
void route_event(uint32_t event_type, void* event_data = nullptr);
|
||||
esp_err_t switch_app(std::shared_ptr<AppDescriptor> app_descriptor);
|
||||
|
||||
/**
|
||||
* @brief Display shutdown screen
|
||||
@@ -91,7 +70,7 @@ public:
|
||||
* @param message Optional message to display (e.g., "Shutting down...")
|
||||
* @return ESP_OK on success, error code otherwise
|
||||
*/
|
||||
esp_err_t show_shutdown_screen(std::string message = "");
|
||||
esp_err_t show_shutdown_screen(const std::string& message = "");
|
||||
|
||||
/**
|
||||
* @brief Get the main screen object
|
||||
@@ -99,35 +78,10 @@ public:
|
||||
* @return lv_obj_t* pointer to the main screen
|
||||
*/
|
||||
lv_obj_t* get_main_screen(void) const {
|
||||
return _main_screen;
|
||||
return main_screen_;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the app container (where apps render)
|
||||
*
|
||||
* @return lv_obj_t* pointer to the app container
|
||||
*/
|
||||
lv_obj_t* get_app_container(void) const {
|
||||
return _root_layout ? _root_layout->get_app_container() : nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the header object
|
||||
*
|
||||
* @return lv_obj_t* pointer to the header container
|
||||
*/
|
||||
lv_obj_t* get_header(void) const {
|
||||
return _root_layout ? _root_layout->get_header() : nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the navigation bar object
|
||||
*
|
||||
* @return lv_obj_t* pointer to the navigation bar container
|
||||
*/
|
||||
lv_obj_t* get_nav_bar(void) const {
|
||||
return _root_layout ? _root_layout->get_nav_bar() : nullptr;
|
||||
}
|
||||
esp_err_t update_header_title(const std::string& title);
|
||||
|
||||
/**
|
||||
* @brief Return to main screen (deinit app and show app icons)
|
||||
@@ -140,8 +94,25 @@ public:
|
||||
esp_err_t return_to_main_screen(void);
|
||||
|
||||
private:
|
||||
lv_obj_t* _main_screen = nullptr; ///< Root screen
|
||||
RootLayout* _root_layout = nullptr; ///< Root layout manager
|
||||
UIApp* _active_app = nullptr; ///< Currently active app
|
||||
UIApp* _shutdown_app = nullptr; ///< Cached shutdown app
|
||||
|
||||
// Handle back button press, route to active app if any
|
||||
void on_back_button_pressed_(void);
|
||||
|
||||
// Helper to create the main screen layout
|
||||
esp_err_t create_main_screen_(lv_obj_t* parent);
|
||||
|
||||
// Helper to destroy the main screen layout
|
||||
esp_err_t destroy_main_screen_(void);
|
||||
|
||||
// delete copy constructor and assignment operator
|
||||
// to prevent copying of the UIHandler instance
|
||||
UIHandler(const UIHandler&) = delete;
|
||||
UIHandler& operator=(const UIHandler&) = delete;
|
||||
|
||||
|
||||
InteractionHandler interaction_handler_; ///< Manages user interactions
|
||||
|
||||
lv_obj_t* main_screen_ = nullptr; ///< Root screen
|
||||
RootLayout root_layout_; ///< Main screen layout manager
|
||||
std::shared_ptr<AppDescriptor> active_descriptor_ = nullptr; ///< Currently active app descriptor (shared ownership)
|
||||
};
|
||||
|
||||
10
main/ui/widgets/textarea.cpp
Normal file
10
main/ui/widgets/textarea.cpp
Normal file
@@ -0,0 +1,10 @@
|
||||
#include "ui/widgets/textarea.h"
|
||||
|
||||
lv_obj_t* textarea_create(lv_obj_t* parent) {
|
||||
lv_obj_t* textarea = lv_textarea_create(parent);
|
||||
// disable animations for cursor and selection for instant response
|
||||
lv_obj_set_style_anim_time(textarea, 0, LV_PART_CURSOR | LV_STATE_FOCUSED);
|
||||
|
||||
return textarea;
|
||||
}
|
||||
|
||||
4
main/ui/widgets/textarea.h
Normal file
4
main/ui/widgets/textarea.h
Normal file
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include "lvgl.h"
|
||||
|
||||
lv_obj_t* textarea_create(lv_obj_t* parent);
|
||||
@@ -8,5 +8,5 @@ phy_init, data, phy, , 0x1000,
|
||||
# OTA Partitions 10MB
|
||||
ota_0, app, ota_0, , 0xA00000,
|
||||
ota_1, app, ota_1, , 0xA00000,
|
||||
# SPIFFS 11MB
|
||||
storage, data, spiffs, , 0xB00000,
|
||||
# LittleFS 11MB
|
||||
storage, data, littlefs, , 0xB00000,
|
||||
|
@@ -587,10 +587,10 @@ CONFIG_ESPTOOLPY_MONITOR_BAUD=115200
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA is not set
|
||||
CONFIG_PARTITION_TABLE_TWO_OTA_LARGE=y
|
||||
# CONFIG_PARTITION_TABLE_CUSTOM is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_two_ota_large.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
||||
CONFIG_PARTITION_TABLE_MD5=y
|
||||
# end of Partition Table
|
||||
@@ -1996,7 +1996,7 @@ CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=y
|
||||
# CONFIG_LITTLEFS_MALLOC_STRATEGY_SPIRAM is not set
|
||||
CONFIG_LITTLEFS_ASSERTS=y
|
||||
# CONFIG_LITTLEFS_MMAP_PARTITION is not set
|
||||
CONFIG_LITTLEFS_WDT_RESET=y
|
||||
# CONFIG_LITTLEFS_WDT_RESET is not set
|
||||
# end of LittleFS
|
||||
|
||||
#
|
||||
@@ -2303,10 +2303,10 @@ CONFIG_LV_USE_WIN=y
|
||||
#
|
||||
CONFIG_LV_USE_THEME_DEFAULT=y
|
||||
# CONFIG_LV_THEME_DEFAULT_DARK is not set
|
||||
CONFIG_LV_THEME_DEFAULT_GROW=y
|
||||
CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=80
|
||||
# CONFIG_LV_THEME_DEFAULT_GROW is not set
|
||||
CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0
|
||||
CONFIG_LV_USE_THEME_SIMPLE=y
|
||||
# CONFIG_LV_USE_THEME_MONO is not set
|
||||
CONFIG_LV_USE_THEME_MONO=y
|
||||
# end of Themes
|
||||
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user