diff --git a/boards/m5stack-tab5.json b/boards/m5stack-tab5.json deleted file mode 100644 index 6ad673ac..00000000 --- a/boards/m5stack-tab5.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "build": { - "arduino": { - "ldscript": "esp32p4_out.ld", - "partitions": "default_16MB.csv", - "memory_type": "opi_opi" - }, - "core": "esp32", - "extra_flags": [ - "-DBOARD_HAS_PSRAM", - "-DARDUINO_USB_MODE=1", - "-DARDUINO_RUNNING_CORE=1", - "-DARDUINO_EVENT_RUNNING_CORE=1" - ], - "f_cpu": "400000000L", - "f_flash": "80000000L", - "flash_mode": "qio", - "mcu": "esp32p4", - "variant": "esp32p4" - }, - "connectivity": [ - "wifi", - "bluetooth" - ], - "debug": { - "default_tool": "esp-builtin", - "onboard_tools": [ - "esp-builtin" - ] - }, - "frameworks": [ - "arduino", - "espidf" - ], - "name": "M5Stack Tab5 (ESP32-P4, 16MB Flash, 32MB PSRAM)", - "upload": { - "flash_size": "16MB", - "maximum_ram_size": 33554432, - "maximum_size": 16777216, - "require_upload_port": true, - "speed": 115200 - }, - "url": "https://docs.m5stack.com/en/core/Tab5", - "vendor": "M5Stack" -} - diff --git a/platformio.ini b/platformio.ini index 1feef462..23a6d3ca 100644 --- a/platformio.ini +++ b/platformio.ini @@ -74,41 +74,6 @@ lib_deps = nanopb/nanopb @ ^0.4.8 rweather/Crypto @ 0.4.0 -; Base configuration for ESP-IDF-based environments (e.g. M5Stack Tab5 / ESP32-P4) -[idf_base] -extends = env -framework = espidf -platform = espressif32@6.12.0 -board_build.filesystem = fatfs -board_build.partitions = partitions.csv - -platform_packages = - toolchain-riscv32-esp@8.4.0+2021r2-patch5 - -build_flags = - -std=gnu++17 - -D LV_CONF_INCLUDE_SIMPLE - -D LV_USE_SNAPSHOT=1 - -D HAS_GPS=1 - -D HAS_SD=1 - -D HAS_PSRAM=1 - -I${PROJECT_DIR}/src/ui - -I${PROJECT_DIR}/src - -I${PROJECT_DIR} - -I${PROJECT_DIR}/src/chat/infra/meshtastic/generated -lib_extra_dirs = - ${PROJECT_DIR}/src/ui -lib_deps = - lvgl/lvgl @ 9.4.0 - jgromes/RadioLib @ 7.4.0 - lewisxhe/XPowersLib@0.3.1 - lewisxhe/SensorLib @ 0.3.3 - mikalhart/TinyGPSPlus @ 1.0.3 - earlephilhower/ESP8266Audio @ 2.0.0 - h2zero/NimBLE-Arduino @ 2.2.3 - nanopb/nanopb @ ^0.4.8 - rweather/Crypto @ 0.4.0 - ; NOTE: Board-specific environments (including tlora_pager_sx1262 / tlora_pager_sx1280) ; are defined in variants/*/envs/*.ini and loaded via extra_configs above. ; Keep root config focused on shared settings only. diff --git a/src/ble/meshcore_ble.cpp b/src/ble/meshcore_ble.cpp index 694ecd1c..504f0921 100644 --- a/src/ble/meshcore_ble.cpp +++ b/src/ble/meshcore_ble.cpp @@ -4,9 +4,13 @@ #include "../chat/domain/contact_types.h" #include "../chat/infra/mesh_adapter_router.h" #include "../chat/infra/meshcore/meshcore_adapter.h" +#include "../display/DisplayConfig.h" +#include "../gps/gps_service_api.h" +#include "../ui/widgets/ble_pairing_popup.h" #include "ble_uuids.h" #include #include +#include #include #include #include @@ -130,6 +134,85 @@ constexpr size_t kPubKeySize = chat::meshcore::MeshCoreIdentity::kPubKeySize; constexpr size_t kPubKeyPrefixSize = 6; constexpr size_t kMaxPathSize = 64; constexpr uint32_t kBleWriteMinIntervalMs = 60; +constexpr bool kMeshCoreBleSecurityEnabled = true; +constexpr uint32_t kMeshCoreDefaultBlePin = 123456; +constexpr uint8_t kMeshCoreCompatFirmwareVerCode = 8; +constexpr uint8_t kMeshCoreCompatMaxContactsDiv2 = 50; +constexpr uint8_t kMeshCoreCompatMaxGroupChannels = 1; +constexpr const char* kMeshCoreCompatFirmwareVersion = "v1.11.0"; + +int8_t encodeSnrQdb(int16_t snr_x10) +{ + if (snr_x10 == std::numeric_limits::min()) + { + return 0; + } + int32_t qdb = (static_cast(snr_x10) * 4) / 10; + if (qdb > 127) + { + qdb = 127; + } + else if (qdb < -128) + { + qdb = -128; + } + return static_cast(qdb); +} + +int8_t encodeRssiDbm(int16_t rssi_x10) +{ + if (rssi_x10 == std::numeric_limits::min()) + { + return 0; + } + int32_t dbm = rssi_x10 / 10; + if (dbm > 127) + { + dbm = 127; + } + else if (dbm < -128) + { + dbm = -128; + } + return static_cast(dbm); +} + +int16_t decodeSnrX10FromQdb(int8_t snr_qdb) +{ + return static_cast((static_cast(snr_qdb) * 10) / 4); +} + +bool parseBoolValue(const char* value, bool* out) +{ + if (!value || !out) + { + return false; + } + if (strcmp(value, "1") == 0 || strcmp(value, "true") == 0 || strcmp(value, "on") == 0 || strcmp(value, "yes") == 0) + { + *out = true; + return true; + } + if (strcmp(value, "0") == 0 || strcmp(value, "false") == 0 || strcmp(value, "off") == 0 || strcmp(value, "no") == 0) + { + *out = false; + return true; + } + return false; +} + +const char* meshCoreCompatManufacturerName() +{ +#if defined(ARDUINO_T_DECK) + return "LilyGo T-Deck"; +#elif defined(ARDUINO_LILYGO_TWATCH_S3) + return "LilyGo T-Watch S3"; +#elif defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) + return "LilyGo T-LoRa-Pager"; +#else + return "TrailMate"; +#endif +} int estimateBatteryMv(int percent) { @@ -176,11 +259,16 @@ void copyBounded(char* dst, size_t dst_len, const char* src) dst[dst_len - 1] = '\0'; } -bool isValidBlePin(uint32_t pin) +bool isConfiguredBlePin(uint32_t pin) { return pin == 0 || (pin >= 100000 && pin <= 999999); } +bool isUsableBlePasskey(uint32_t pin) +{ + return pin >= 100000 && pin <= 999999; +} + } // namespace namespace ble @@ -193,7 +281,6 @@ class MeshCoreRxCallbacks : public NimBLECharacteristicCallbacks void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { - (void)connInfo; if (!characteristic) { return; @@ -201,8 +288,19 @@ class MeshCoreRxCallbacks : public NimBLECharacteristicCallbacks NimBLEAttValue val = characteristic->getValue(); if (val.length() == 0 || val.length() > kMaxFrameSize) { + Serial.printf("[BLE][meshcore] rx write ignored len=%u handle=%u enc=%u auth=%u\n", + static_cast(val.length()), + static_cast(connInfo.getConnHandle()), + connInfo.isEncrypted() ? 1U : 0U, + connInfo.isAuthenticated() ? 1U : 0U); return; } + Serial.printf("[BLE][meshcore] rx write len=%u cmd=%u handle=%u enc=%u auth=%u\n", + static_cast(val.length()), + static_cast(val.data()[0]), + static_cast(connInfo.getConnHandle()), + connInfo.isEncrypted() ? 1U : 0U, + connInfo.isAuthenticated() ? 1U : 0U); MeshCoreBleService::Frame frame; frame.len = static_cast(val.length()); memcpy(frame.buf.data(), val.data(), frame.len); @@ -213,28 +311,104 @@ class MeshCoreRxCallbacks : public NimBLECharacteristicCallbacks MeshCoreBleService& owner_; }; +class MeshCoreTxCallbacks : public NimBLECharacteristicCallbacks +{ + public: + explicit MeshCoreTxCallbacks(MeshCoreBleService& owner) : owner_(owner) {} + + void onSubscribe(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo, uint16_t subValue) override + { + (void)characteristic; + owner_.tx_subscribed_ = (subValue & 0x0001U) != 0; + Serial.printf("[BLE][meshcore] tx subscribe sub=0x%X handle=%u\n", + static_cast(subValue), + static_cast(connInfo.getConnHandle())); + } + + private: + MeshCoreBleService& owner_; +}; + class MeshCoreServerCallbacks : public NimBLEServerCallbacks { public: explicit MeshCoreServerCallbacks(MeshCoreBleService& owner) : owner_(owner) {} + uint32_t onPassKeyDisplay() override + { + owner_.refreshBlePin(); + const uint32_t passkey = owner_.effectiveBlePin(); + owner_.pending_passkey_.store(passkey); + Serial.printf("[BLE][meshcore] pairing passkey=%06lu\n", static_cast(passkey)); + return passkey; + } + + void onAuthenticationComplete(NimBLEConnInfo& connInfo) override + { + const bool authenticated = connInfo.isEncrypted() && connInfo.isAuthenticated(); + owner_.connected_ = kMeshCoreBleSecurityEnabled ? authenticated : true; + owner_.negotiated_mtu_ = connInfo.getMTU(); + owner_.pending_passkey_.store(0); + Serial.printf("[BLE][meshcore] auth complete bonded=%u enc=%u auth=%u handle=%u\n", + connInfo.isBonded() ? 1U : 0U, + connInfo.isEncrypted() ? 1U : 0U, + connInfo.isAuthenticated() ? 1U : 0U, + static_cast(connInfo.getConnHandle())); + } + + void onMTUChange(uint16_t mtu, NimBLEConnInfo& connInfo) override + { + owner_.negotiated_mtu_ = mtu; + Serial.printf("[BLE][meshcore] mtu update handle=%u mtu=%u enc=%u auth=%u\n", + static_cast(connInfo.getConnHandle()), + static_cast(mtu), + connInfo.isEncrypted() ? 1U : 0U, + connInfo.isAuthenticated() ? 1U : 0U); + } + void onConnect(NimBLEServer* server, NimBLEConnInfo& connInfo) override { (void)server; - (void)connInfo; - owner_.connected_ = true; + owner_.connected_ = !kMeshCoreBleSecurityEnabled; + owner_.tx_subscribed_ = false; + owner_.conn_handle_ = connInfo.getConnHandle(); + owner_.conn_handle_valid_ = true; + owner_.negotiated_mtu_ = connInfo.getMTU(); + owner_.pending_passkey_.store(0); + if (kMeshCoreBleSecurityEnabled) + { + owner_.refreshBlePin(); + if (!connInfo.isEncrypted()) + { + int rc = 0; + const bool ok = NimBLEDevice::startSecurity(connInfo.getConnHandle(), &rc); + Serial.printf("[BLE][meshcore] startSecurity handle=%u ok=%u rc=%d\n", + static_cast(connInfo.getConnHandle()), + ok ? 1U : 0U, + rc); + } + } + Serial.printf("[BLE][meshcore] connected addr=%s mtu=%u bonded=%u\n", + connInfo.getAddress().toString().c_str(), + static_cast(connInfo.getMTU()), + connInfo.isBonded() ? 1U : 0U); } void onDisconnect(NimBLEServer* server, NimBLEConnInfo& connInfo, int reason) override { (void)server; (void)connInfo; - (void)reason; owner_.connected_ = false; + owner_.tx_subscribed_ = false; + owner_.conn_handle_valid_ = false; + owner_.conn_handle_ = 0; + owner_.negotiated_mtu_ = 23; + owner_.pending_passkey_.store(0); owner_.outbound_.clear(); owner_.rx_queue_.clear(); owner_.startAdvertising(); - Serial.printf("[BLE][meshcore] disconnected; advertising restarted uuid=%s\n", + Serial.printf("[BLE][meshcore] disconnected reason=%d; advertising restarted uuid=%s\n", + reason, NUS_SERVICE_UUID); } @@ -255,6 +429,261 @@ MeshCoreBleService::~MeshCoreBleService() stop(); } +uint32_t MeshCoreBleService::effectiveBlePin() const +{ + return active_ble_pin_; +} + +void MeshCoreBleService::refreshBlePin() +{ + if (!kMeshCoreBleSecurityEnabled) + { + active_ble_pin_ = 0; + return; + } + + if (ble_pin_ != 0 && isUsableBlePasskey(ble_pin_)) + { + active_ble_pin_ = ble_pin_; + NimBLEDevice::setSecurityPasskey(active_ble_pin_); + return; + } + + active_ble_pin_ = kMeshCoreDefaultBlePin; + NimBLEDevice::setSecurityPasskey(active_ble_pin_); +} + +void MeshCoreBleService::noteLinkStats(int16_t rssi_dbm_x10, int16_t snr_db_x10) +{ + if (rssi_dbm_x10 != std::numeric_limits::min()) + { + last_rssi_dbm_x10_ = rssi_dbm_x10; + } + if (snr_db_x10 != std::numeric_limits::min()) + { + last_snr_db_x10_ = snr_db_x10; + } +} + +void MeshCoreBleService::noteRxMeta(const chat::RxMeta& rx_meta) +{ + noteLinkStats(rx_meta.rssi_dbm_x10, rx_meta.snr_db_x10); + ++stats_rx_packets_; + if (rx_meta.direct) + { + ++stats_rx_direct_; + } + else + { + ++stats_rx_flood_; + } +} + +void MeshCoreBleService::noteEventRx(int8_t rssi_dbm, int8_t snr_qdb) +{ + noteLinkStats(static_cast(rssi_dbm) * 10, decodeSnrX10FromQdb(snr_qdb)); + ++stats_rx_packets_; +} + +void MeshCoreBleService::noteSentRoute(bool sent_flood) +{ + ++stats_tx_packets_; + if (sent_flood) + { + ++stats_tx_flood_; + } + else + { + ++stats_tx_direct_; + } +} + +void MeshCoreBleService::appendCustomVar(std::string& out, const char* key, const char* value) const +{ + if (!key || !value) + { + return; + } + if (!out.empty()) + { + out.push_back(','); + } + out += key; + out.push_back(':'); + out += value; +} + +bool MeshCoreBleService::handleCustomVarSet(const char* key, const char* value) +{ + if (!key || !value) + { + return false; + } + + auto& cfg = ctx_.getConfig(); + bool save_cfg = false; + bool apply_mesh = false; + bool apply_user = false; + bool apply_position = false; + bool save_ble = false; + + if (strcmp(key, "node_name") == 0) + { + copyBounded(cfg.node_name, sizeof(cfg.node_name), value); + save_cfg = true; + apply_user = true; + } + else if (strcmp(key, "channel_name") == 0) + { + copyBounded(cfg.meshcore_config.meshcore_channel_name, + sizeof(cfg.meshcore_config.meshcore_channel_name), value); + save_cfg = true; + apply_mesh = true; + } + else if (strcmp(key, "ble_pin") == 0) + { + const uint32_t pin = static_cast(strtoul(value, nullptr, 10)); + if (!isConfiguredBlePin(pin)) + { + return false; + } + ble_pin_ = pin; + if (kMeshCoreBleSecurityEnabled) + { + refreshBlePin(); + } + save_ble = true; + } + else if (strcmp(key, "manual_add_contacts") == 0) + { + bool parsed = false; + if (!parseBoolValue(value, &parsed)) + { + return false; + } + manual_add_contacts_ = parsed; + save_ble = true; + } + else if (strcmp(key, "telemetry_base") == 0) + { + const long parsed = strtol(value, nullptr, 10); + if (parsed < 0 || parsed > 3) + { + return false; + } + telemetry_mode_base_ = static_cast(parsed); + save_ble = true; + } + else if (strcmp(key, "telemetry_loc") == 0) + { + const long parsed = strtol(value, nullptr, 10); + if (parsed < 0 || parsed > 3) + { + return false; + } + telemetry_mode_loc_ = static_cast(parsed); + save_ble = true; + } + else if (strcmp(key, "telemetry_env") == 0) + { + const long parsed = strtol(value, nullptr, 10); + if (parsed < 0 || parsed > 3) + { + return false; + } + telemetry_mode_env_ = static_cast(parsed); + save_ble = true; + } + else if (strcmp(key, "advert_loc_policy") == 0) + { + const long parsed = strtol(value, nullptr, 10); + if (parsed < 0 || parsed > 255) + { + return false; + } + advert_loc_policy_ = static_cast(parsed); + save_ble = true; + } + else if (strcmp(key, "multi_acks") == 0) + { + bool parsed = false; + if (!parseBoolValue(value, &parsed)) + { + return false; + } + cfg.meshcore_config.meshcore_multi_acks = parsed; + multi_acks_ = parsed ? 1 : 0; + save_cfg = true; + apply_mesh = true; + save_ble = true; + } + else if (strcmp(key, "gps") == 0) + { + bool parsed = false; + if (!parseBoolValue(value, &parsed)) + { + return false; + } + if (parsed) + { + if (cfg.gps_strategy == 2) + { + cfg.gps_strategy = 0; + } + } + else + { + cfg.gps_strategy = 2; + } + save_cfg = true; + apply_position = true; + } + else + { + return false; + } + + if (save_ble) + { + saveBlePin(); + } + if (save_cfg) + { + ctx_.saveConfig(); + } + if (apply_user) + { + ctx_.applyUserInfo(); + } + if (apply_mesh) + { + ctx_.applyMeshConfig(); + } + if (apply_position) + { + ctx_.applyPositionConfig(); + gps::gps_set_power_strategy(cfg.gps_strategy); + } + return true; +} + +void MeshCoreBleService::enqueueRawDataPush(const uint8_t* payload, size_t len, const chat::RxMeta* meta) +{ + uint8_t out[kMaxFrameSize] = {}; + int i = 0; + out[i++] = PUSH_CODE_RAW_DATA; + out[i++] = meta ? static_cast(encodeSnrQdb(meta->snr_db_x10)) : 0; + out[i++] = meta ? static_cast(encodeRssiDbm(meta->rssi_dbm_x10)) : 0; + out[i++] = 0xFF; + size_t copy_len = std::min(len, static_cast(kMaxFrameSize - i)); + if (payload && copy_len > 0) + { + memcpy(&out[i], payload, copy_len); + i += static_cast(copy_len); + } + enqueueFrame(out, i); +} + chat::meshcore::MeshCoreAdapter* MeshCoreBleService::meshCoreAdapter() { auto* adapter = ctx_.getMeshAdapter(); @@ -284,6 +713,14 @@ const chat::meshcore::MeshCoreAdapter* MeshCoreBleService::meshCoreAdapter() con void MeshCoreBleService::start() { NimBLEDevice::setMTU(185); + if (kMeshCoreBleSecurityEnabled) + { + refreshBlePin(); + NimBLEDevice::setSecurityAuth(BLE_SM_PAIR_AUTHREQ_BOND | BLE_SM_PAIR_AUTHREQ_MITM | BLE_SM_PAIR_AUTHREQ_SC); + NimBLEDevice::setSecurityInitKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID); + NimBLEDevice::setSecurityRespKey(BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID); + NimBLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY); + } setupService(); startAdvertising(); @@ -298,6 +735,8 @@ void MeshCoreBleService::start() void MeshCoreBleService::stop() { + pending_passkey_.store(0); + ::ui::BlePairingPopup::hide(); ctx_.getChatService().removeIncomingTextObserver(this); if (auto* team = ctx_.getTeamService()) { @@ -320,6 +759,13 @@ void MeshCoreBleService::stop() void MeshCoreBleService::update() { + if (kMeshCoreBleSecurityEnabled) + { + ::ui::BlePairingPopup::update( + pending_passkey_.load(), + true, + device_name_.c_str()); + } handleIncomingFrames(); auto* adapter = meshCoreAdapter(); if (adapter) @@ -387,6 +833,7 @@ void MeshCoreBleService::update() { case chat::meshcore::MeshCoreAdapter::Event::Type::Response: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); if (ev.payload.size() < 4) { break; @@ -520,6 +967,7 @@ void MeshCoreBleService::update() } case chat::meshcore::MeshCoreAdapter::Event::Type::PathResponse: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); if (pending_discovery_ == 0 || ev.tag != pending_discovery_) { break; @@ -554,6 +1002,7 @@ void MeshCoreBleService::update() } case chat::meshcore::MeshCoreAdapter::Event::Type::ControlData: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); uint8_t out[kMaxFrameSize] = {}; int i = 0; out[i++] = PUSH_CODE_CONTROL_DATA; @@ -571,6 +1020,7 @@ void MeshCoreBleService::update() } case chat::meshcore::MeshCoreAdapter::Event::Type::RawData: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); uint8_t out[kMaxFrameSize] = {}; int i = 0; out[i++] = PUSH_CODE_RAW_DATA; @@ -588,6 +1038,7 @@ void MeshCoreBleService::update() } case chat::meshcore::MeshCoreAdapter::Event::Type::TraceData: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); uint8_t out[kMaxFrameSize] = {}; int i = 0; out[i++] = PUSH_CODE_TRACE_DATA; @@ -620,6 +1071,7 @@ void MeshCoreBleService::update() } case chat::meshcore::MeshCoreAdapter::Event::Type::Advert: { + noteEventRx(ev.rssi_dbm, ev.snr_qdb); uint8_t pubkey[kPubKeySize] = {}; fillPubkey(ev.peer_hash, ev.peer_node, pubkey); bool known = false; @@ -684,26 +1136,10 @@ void MeshCoreBleService::update() void MeshCoreBleService::onIncomingText(const chat::MeshIncomingText& msg) { + noteRxMeta(msg.rx_meta); Frame frame; int i = 0; const bool use_v3 = (app_target_ver_ >= 3); - auto encodeSnrQdb = [](int16_t snr_x10) -> int8_t - { - if (snr_x10 == std::numeric_limits::min()) - { - return 0; - } - int32_t qdb = (static_cast(snr_x10) * 4) / 10; - if (qdb > 127) - { - qdb = 127; - } - else if (qdb < -128) - { - qdb = -128; - } - return static_cast(qdb); - }; const uint8_t path_len = msg.rx_meta.direct ? 0xFF : msg.rx_meta.hop_count; if (msg.to == 0xFFFFFFFF || msg.to == 0) { @@ -764,7 +1200,8 @@ void MeshCoreBleService::onIncomingText(const chat::MeshIncomingText& msg) void MeshCoreBleService::onIncomingData(const chat::MeshIncomingData& msg) { - (void)msg; + noteRxMeta(msg.rx_meta); + enqueueRawDataPush(msg.payload.data(), msg.payload.size(), &msg.rx_meta); } void MeshCoreBleService::setupService() @@ -773,10 +1210,24 @@ void MeshCoreBleService::setupService() server_->setCallbacks(new MeshCoreServerCallbacks(*this)); service_ = server_->createService(NUS_SERVICE_UUID); - tx_char_ = service_->createCharacteristic(NUS_CHAR_TX_UUID, - NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + if (kMeshCoreBleSecurityEnabled) + { + tx_char_ = service_->createCharacteristic(NUS_CHAR_TX_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | + NIMBLE_PROPERTY::READ_AUTHEN | NIMBLE_PROPERTY::READ_ENC); - rx_char_ = service_->createCharacteristic(NUS_CHAR_RX_UUID, NIMBLE_PROPERTY::WRITE); + rx_char_ = service_->createCharacteristic(NUS_CHAR_RX_UUID, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_AUTHEN | + NIMBLE_PROPERTY::WRITE_ENC); + } + else + { + tx_char_ = service_->createCharacteristic(NUS_CHAR_TX_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + + rx_char_ = service_->createCharacteristic(NUS_CHAR_RX_UUID, NIMBLE_PROPERTY::WRITE); + } + tx_char_->setCallbacks(new MeshCoreTxCallbacks(*this)); rx_char_->setCallbacks(new MeshCoreRxCallbacks(*this)); service_->start(); @@ -789,16 +1240,24 @@ void MeshCoreBleService::startAdvertising() return; } NimBLEAdvertising* adv = server_->getAdvertising(); + const bool reset_ok = adv->reset(); + const bool conn_ok = adv->setConnectableMode(BLE_GAP_CONN_MODE_UND); + const bool disc_ok = adv->setDiscoverableMode(BLE_GAP_DISC_MODE_GEN); const bool service_ok = adv->addServiceUUID(NUS_SERVICE_UUID); adv->enableScanResponse(true); const bool name_ok = adv->setName(device_name_); const bool start_ok = adv->start(); - Serial.printf("[BLE][meshcore] advertising uuid=%s name=%s service_ok=%u name_ok=%u start_ok=%u\n", + const bool active_ok = adv->isAdvertising(); + Serial.printf("[BLE][meshcore] advertising uuid=%s name=%s reset_ok=%u conn_ok=%u disc_ok=%u service_ok=%u name_ok=%u start_ok=%u active_ok=%u\n", NUS_SERVICE_UUID, device_name_.c_str(), + reset_ok ? 1U : 0U, + conn_ok ? 1U : 0U, + disc_ok ? 1U : 0U, service_ok ? 1U : 0U, name_ok ? 1U : 0U, - start_ok ? 1U : 0U); + start_ok ? 1U : 0U, + active_ok ? 1U : 0U); } void MeshCoreBleService::handleIncomingFrames() @@ -842,20 +1301,24 @@ void MeshCoreBleService::handleCmdFrame(size_t len) if (cmd == CMD_DEVICE_QEURY && len >= 2) { app_target_ver_ = cmd_frame_[1]; + Serial.printf("[BLE][meshcore] cmd device_query ver=%u len=%u\n", + static_cast(app_target_ver_), + static_cast(len)); uint8_t out[kMaxFrameSize] = {}; int i = 0; out[i++] = RESP_CODE_DEVICE_INFO; - out[i++] = 8; - out[i++] = 50; - out[i++] = 2; - memcpy(&out[i], &ble_pin_, 4); + out[i++] = kMeshCoreCompatFirmwareVerCode; + out[i++] = kMeshCoreCompatMaxContactsDiv2; + out[i++] = kMeshCoreCompatMaxGroupChannels; + uint32_t reported_pin = ble_pin_; + memcpy(&out[i], &reported_pin, 4); i += 4; memset(&out[i], 0, 12); strncpy(reinterpret_cast(&out[i]), __DATE__, 11); i += 12; - strncpy(reinterpret_cast(&out[i]), "TrailMate", 40); + strncpy(reinterpret_cast(&out[i]), meshCoreCompatManufacturerName(), 40); i += 40; - strncpy(reinterpret_cast(&out[i]), "trail-mate", 20); + strncpy(reinterpret_cast(&out[i]), kMeshCoreCompatFirmwareVersion, 20); i += 20; enqueueFrame(out, i); return; @@ -863,6 +1326,8 @@ void MeshCoreBleService::handleCmdFrame(size_t len) if (cmd == CMD_APP_START && len >= 8) { + Serial.printf("[BLE][meshcore] cmd app_start len=%u\n", + static_cast(len)); contacts_iter_active_ = false; contacts_frames_.clear(); uint8_t out[kMaxFrameSize] = {}; @@ -880,6 +1345,12 @@ void MeshCoreBleService::handleCmdFrame(size_t len) i += kPubKeySize; int32_t lat = 0; int32_t lon = 0; + const gps::GpsState gps_state = gps::gps_get_data(); + if (gps_state.valid) + { + lat = static_cast(gps_state.lat * 1000000.0); + lon = static_cast(gps_state.lng * 1000000.0); + } memcpy(&out[i], &lat, 4); i += 4; memcpy(&out[i], &lon, 4); @@ -965,6 +1436,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) memcpy(&out[2], &expected_ack, 4); memcpy(&out[6], &est_timeout, 4); enqueueFrame(out, sizeof(out)); + noteSentRoute(sent_flood); return; } @@ -992,6 +1464,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) writeErr(ERR_CODE_TABLE_FULL); return; } + noteSentRoute(true); writeOk(); return; } @@ -1064,6 +1537,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) uint32_t prefix4 = 0; memcpy(&prefix4, pubkey, sizeof(prefix4)); pending_login_ = prefix4; + noteSentRoute(sent_flood); uint8_t out[10] = {}; out[0] = RESP_CODE_SENT; out[1] = sent_flood ? 1 : 0; @@ -1102,6 +1576,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) memcpy(&prefix4, pubkey, sizeof(prefix4)); pending_status_ = prefix4; pending_status_tag_ = tag; + noteSentRoute(sent_flood); uint8_t out[10] = {}; out[0] = RESP_CODE_SENT; out[1] = sent_flood ? 1 : 0; @@ -1336,8 +1811,13 @@ void MeshCoreBleService::handleCmdFrame(size_t len) return; } - if (cmd == CMD_REBOOT) + if (cmd == CMD_REBOOT && len >= 7) { + if (memcmp(&cmd_frame_[1], "reboot", 6) != 0) + { + writeErr(ERR_CODE_ILLEGAL_ARG); + return; + } writeOk(); delay(200); ESP.restart(); @@ -1395,6 +1875,15 @@ void MeshCoreBleService::handleCmdFrame(size_t len) uint16_t mv = static_cast(estimateBatteryMv(percent)); uint32_t used = 0; uint32_t total = 0; +#if defined(HAS_SD) && HAS_SD + if (board.isSDReady()) + { + const uint64_t total_bytes = SD.totalBytes(); + const uint64_t used_bytes = SD.usedBytes(); + total = static_cast(std::min(total_bytes / 1024ULL, std::numeric_limits::max())); + used = static_cast(std::min(used_bytes / 1024ULL, std::numeric_limits::max())); + } +#endif memcpy(&out[1], &mv, 2); memcpy(&out[3], &used, 4); memcpy(&out[7], &total, 4); @@ -1482,6 +1971,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) ctx_.saveConfig(); ctx_.applyMeshConfig(); } + saveBlePin(); writeOk(); return; } @@ -1550,12 +2040,16 @@ void MeshCoreBleService::handleCmdFrame(size_t len) if (cmd == CMD_SET_DEVICE_PIN && len >= 5) { uint32_t pin = readUint32LE(&cmd_frame_[1]); - if (!isValidBlePin(pin)) + if (!isConfiguredBlePin(pin)) { writeErr(ERR_CODE_ILLEGAL_ARG); return; } ble_pin_ = pin; + if (kMeshCoreBleSecurityEnabled) + { + refreshBlePin(); + } saveBlePin(); writeOk(); return; @@ -1637,6 +2131,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) } clearPendingRequests(); pending_discovery_ = tag; + noteSentRoute(sent_flood); uint8_t out[10] = {}; out[0] = RESP_CODE_SENT; out[1] = sent_flood ? 1 : 0; @@ -1849,14 +2344,56 @@ void MeshCoreBleService::handleCmdFrame(size_t len) if (cmd == CMD_GET_CUSTOM_VARS) { - uint8_t out = RESP_CODE_CUSTOM_VARS; - enqueueFrame(&out, 1); + std::string vars; + char buf[40]; + if (gps::gps_is_enabled()) + { + appendCustomVar(vars, "gps", gps::gps_is_powered() ? "1" : "0"); + } + appendCustomVar(vars, "node_name", cfg.node_name); + appendCustomVar(vars, "channel_name", cfg.meshcore_config.meshcore_channel_name); + const uint32_t reported_pin = ble_pin_; + snprintf(buf, sizeof(buf), "%lu", static_cast(reported_pin)); + appendCustomVar(vars, "ble_pin", buf); + appendCustomVar(vars, "manual_add_contacts", manual_add_contacts_ ? "1" : "0"); + snprintf(buf, sizeof(buf), "%u", static_cast(telemetry_mode_base_)); + appendCustomVar(vars, "telemetry_base", buf); + snprintf(buf, sizeof(buf), "%u", static_cast(telemetry_mode_loc_)); + appendCustomVar(vars, "telemetry_loc", buf); + snprintf(buf, sizeof(buf), "%u", static_cast(telemetry_mode_env_)); + appendCustomVar(vars, "telemetry_env", buf); + snprintf(buf, sizeof(buf), "%u", static_cast(advert_loc_policy_)); + appendCustomVar(vars, "advert_loc_policy", buf); + appendCustomVar(vars, "multi_acks", cfg.meshcore_config.meshcore_multi_acks ? "1" : "0"); + + uint8_t out[kMaxFrameSize] = {}; + out[0] = RESP_CODE_CUSTOM_VARS; + const size_t copy_len = std::min(vars.size(), static_cast(kMaxFrameSize - 1)); + if (copy_len > 0) + { + memcpy(&out[1], vars.data(), copy_len); + } + enqueueFrame(out, copy_len + 1); return; } - if (cmd == CMD_SET_CUSTOM_VAR) + if (cmd == CMD_SET_CUSTOM_VAR && len >= 4) { - writeErr(ERR_CODE_UNSUPPORTED_CMD); + cmd_frame_[len] = 0; + char* sp = reinterpret_cast(&cmd_frame_[1]); + char* np = strchr(sp, ':'); + if (!np) + { + writeErr(ERR_CODE_ILLEGAL_ARG); + return; + } + *np++ = 0; + if (!handleCustomVarSet(sp, np)) + { + writeErr(ERR_CODE_ILLEGAL_ARG); + return; + } + writeOk(); return; } @@ -1895,6 +2432,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) } clearPendingRequests(); pending_req_ = tag; + noteSentRoute(sent_flood); uint8_t out[10] = {}; out[0] = RESP_CODE_SENT; out[1] = sent_flood ? 1 : 0; @@ -1986,10 +2524,17 @@ void MeshCoreBleService::handleCmdFrame(size_t len) out[i++] = RESP_CODE_STATS; out[i++] = STATS_TYPE_RADIO; int16_t noise_floor = 0; - int8_t last_rssi = 0; - int8_t last_snr = 0; uint32_t tx_air = 0; uint32_t rx_air = 0; + if (adapter) + { + const auto radio_stats = adapter->getRadioStats(); + noise_floor = radio_stats.noise_floor_dbm; + tx_air = radio_stats.tx_airtime_ms / 1000; + rx_air = radio_stats.rx_airtime_ms / 1000; + } + int8_t last_rssi = encodeRssiDbm(last_rssi_dbm_x10_); + int8_t last_snr = encodeSnrQdb(last_snr_db_x10_); memcpy(&out[i], &noise_floor, 2); i += 2; out[i++] = static_cast(last_rssi); @@ -2007,9 +2552,16 @@ void MeshCoreBleService::handleCmdFrame(size_t len) int i = 0; out[i++] = RESP_CODE_STATS; out[i++] = STATS_TYPE_PACKETS; - uint32_t zeros[6] = {}; - memcpy(&out[i], zeros, sizeof(zeros)); - i += sizeof(zeros); + uint32_t counts[6] = { + stats_rx_packets_, + stats_tx_packets_, + stats_tx_flood_, + stats_tx_direct_, + stats_rx_flood_, + stats_rx_direct_, + }; + memcpy(&out[i], counts, sizeof(counts)); + i += sizeof(counts); enqueueFrame(out, i); return; } @@ -2039,6 +2591,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) writeErr(ERR_CODE_TABLE_FULL); return; } + noteSentRoute(false); writeOk(); } else @@ -2093,6 +2646,7 @@ void MeshCoreBleService::handleCmdFrame(size_t len) } if (adapter->sendControlData(&cmd_frame_[1], len - 1)) { + noteSentRoute(false); writeOk(); } else @@ -2115,9 +2669,19 @@ void MeshCoreBleService::handleCmdFrame(size_t len) manual_contacts_.clear(); saveManualContacts(); ble_pin_ = 0; + manual_add_contacts_ = false; + telemetry_mode_base_ = 0; + telemetry_mode_loc_ = 0; + telemetry_mode_env_ = 0; + advert_loc_policy_ = 0; + if (kMeshCoreBleSecurityEnabled) + { + refreshBlePin(); + } saveBlePin(); connections_.clear(); known_peer_hashes_.clear(); + multi_acks_ = 0; if (adapter) { adapter->setFloodScopeKey(nullptr, 0); @@ -2222,7 +2786,7 @@ void MeshCoreBleService::enqueueOffline(const uint8_t* data, size_t len) void MeshCoreBleService::sendPendingFrames() { - if (!connected_ || !tx_char_ || outbound_.empty()) + if (!connected_ || !tx_char_ || !tx_subscribed_ || !conn_handle_valid_ || outbound_.empty()) { return; } @@ -2233,9 +2797,35 @@ void MeshCoreBleService::sendPendingFrames() } Frame frame = outbound_.front(); - outbound_.pop_front(); + const uint16_t mtu = negotiated_mtu_; + const uint16_t att_payload_max = (mtu > 3U) ? static_cast(mtu - 3U) : 0U; + if (frame.len > att_payload_max) + { + static uint32_t last_mtu_wait_log_ms = 0; + const uint32_t now_ms = millis(); + if (now_ms - last_mtu_wait_log_ms > 500) + { + Serial.printf("[BLE][meshcore] tx wait mtu=%u need=%u code=%u q=%u\n", + static_cast(mtu), + static_cast(frame.len + 3U), + static_cast(frame.buf[0]), + static_cast(outbound_.size())); + last_mtu_wait_log_ms = now_ms; + } + return; + } tx_char_->setValue(frame.buf.data(), frame.len); - tx_char_->notify(); + const bool notify_ok = tx_char_->notify(conn_handle_); + Serial.printf("[BLE][meshcore] tx notify len=%u code=%u ok=%u mtu=%u q=%u\n", + static_cast(frame.len), + static_cast(frame.buf[0]), + notify_ok ? 1U : 0U, + static_cast(mtu), + static_cast(outbound_.size())); + if (notify_ok) + { + outbound_.pop_front(); + } last_write_ms_ = now; } @@ -2255,8 +2845,17 @@ void MeshCoreBleService::loadBlePin() if (prefs.begin("mc_ble", true)) { ble_pin_ = prefs.getUInt("pin", 0); + manual_add_contacts_ = prefs.getBool("manual_add", false); + telemetry_mode_base_ = prefs.getUChar("telem_base", 0); + telemetry_mode_loc_ = prefs.getUChar("telem_loc", 0); + telemetry_mode_env_ = prefs.getUChar("telem_env", 0); + advert_loc_policy_ = prefs.getUChar("advert_loc", 0); prefs.end(); } + if (kMeshCoreBleSecurityEnabled) + { + refreshBlePin(); + } } void MeshCoreBleService::saveBlePin() @@ -2265,6 +2864,11 @@ void MeshCoreBleService::saveBlePin() if (prefs.begin("mc_ble", false)) { prefs.putUInt("pin", ble_pin_); + prefs.putBool("manual_add", manual_add_contacts_); + prefs.putUChar("telem_base", telemetry_mode_base_); + prefs.putUChar("telem_loc", telemetry_mode_loc_); + prefs.putUChar("telem_env", telemetry_mode_env_); + prefs.putUChar("advert_loc", advert_loc_policy_); prefs.end(); } } diff --git a/src/ble/meshcore_ble.h b/src/ble/meshcore_ble.h index 2c5402a4..e764c515 100644 --- a/src/ble/meshcore_ble.h +++ b/src/ble/meshcore_ble.h @@ -8,6 +8,7 @@ #include "ble_manager.h" #include #include +#include #include #include #include @@ -32,6 +33,7 @@ class MeshCoreBleService : public BleService, private: friend class MeshCoreRxCallbacks; + friend class MeshCoreTxCallbacks; friend class MeshCoreServerCallbacks; struct Frame @@ -61,6 +63,10 @@ class MeshCoreBleService : public BleService, NimBLECharacteristic* rx_char_ = nullptr; NimBLECharacteristic* tx_char_ = nullptr; bool connected_ = false; + bool tx_subscribed_ = false; + uint16_t conn_handle_ = 0; + bool conn_handle_valid_ = false; + uint16_t negotiated_mtu_ = 23; std::deque outbound_; std::deque rx_queue_; @@ -89,6 +95,16 @@ class MeshCoreBleService : public BleService, std::vector sign_data_; bool sign_active_ = false; uint32_t ble_pin_ = 0; + uint32_t active_ble_pin_ = 0; + std::atomic pending_passkey_{0}; + int16_t last_rssi_dbm_x10_ = 0; + int16_t last_snr_db_x10_ = 0; + uint32_t stats_tx_packets_ = 0; + uint32_t stats_rx_packets_ = 0; + uint32_t stats_tx_flood_ = 0; + uint32_t stats_tx_direct_ = 0; + uint32_t stats_rx_flood_ = 0; + uint32_t stats_rx_direct_ = 0; int32_t advert_lat_ = 0; int32_t advert_lon_ = 0; uint32_t pending_login_ = 0; @@ -119,6 +135,15 @@ class MeshCoreBleService : public BleService, void saveManualContacts(); void loadBlePin(); void saveBlePin(); + uint32_t effectiveBlePin() const; + void refreshBlePin(); + void noteLinkStats(int16_t rssi_dbm_x10, int16_t snr_db_x10); + void noteRxMeta(const chat::RxMeta& rx_meta); + void noteEventRx(int8_t rssi_dbm, int8_t snr_qdb); + void noteSentRoute(bool sent_flood); + void enqueueRawDataPush(const uint8_t* payload, size_t len, const chat::RxMeta* meta); + bool handleCustomVarSet(const char* key, const char* value); + void appendCustomVar(std::string& out, const char* key, const char* value) const; ContactRecord* findManualContact(const uint8_t* pubkey); const ContactRecord* findManualContactByPrefix(const uint8_t* prefix, size_t len) const; bool resolveContactByPubkey(const uint8_t* pubkey, diff --git a/src/ble/meshtastic_ble.cpp b/src/ble/meshtastic_ble.cpp index affc543c..730200ab 100644 --- a/src/ble/meshtastic_ble.cpp +++ b/src/ble/meshtastic_ble.cpp @@ -945,8 +945,6 @@ class MeshtasticBleService::PhoneApi return meshtastic_HardwareModel_T_DECK; #elif defined(ARDUINO_LILYGO_TWATCH_S3) return meshtastic_HardwareModel_T_WATCH_S3; -#elif defined(ARDUINO_M5STACK_TAB5) - return meshtastic_HardwareModel_MESH_TAB; #elif defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) return meshtastic_HardwareModel_T_LORA_PAGER; #else @@ -1842,6 +1840,55 @@ class MeshtasticBleService::PhoneApi return true; } + if (packet.decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP && packet.decoded.want_response && + packet.decoded.payload.size > 0) + { + meshtastic_RouteDiscovery route = meshtastic_RouteDiscovery_init_zero; + pb_istream_t req_stream = + pb_istream_from_buffer(packet.decoded.payload.bytes, packet.decoded.payload.size); + if (!pb_decode(&req_stream, meshtastic_RouteDiscovery_fields, &route)) + { + ble_log("self traceroute decode fail req_id=%lu", static_cast(packet.id)); + return false; + } + + if (route.snr_towards_count < 8) + { + route.snr_towards[route.snr_towards_count++] = 0; + } + + meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero; + reply.from = self; + reply.to = self; + reply.channel = packet.channel; + reply.id = normalizePacketId(0); + reply.rx_time = nowSeconds(); + reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + zeroInit(reply.decoded); + reply.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP; + reply.decoded.dest = self; + reply.decoded.source = self; + reply.decoded.request_id = packet.id; + reply.decoded.want_response = false; + reply.decoded.has_bitfield = true; + reply.decoded.bitfield = 0; + + pb_ostream_t out_stream = + pb_ostream_from_buffer(reply.decoded.payload.bytes, sizeof(reply.decoded.payload.bytes)); + if (!pb_encode(&out_stream, meshtastic_RouteDiscovery_fields, &route)) + { + return false; + } + reply.decoded.payload.size = static_cast(out_stream.bytes_written); + packet_queue_.push_back(reply); + + ble_log("self traceroute reply req_id=%lu resp_id=%lu len=%u", + static_cast(packet.id), + static_cast(reply.id), + static_cast(reply.decoded.payload.size)); + return true; + } + return false; } diff --git a/src/board/M5Tab5Board.cpp b/src/board/M5Tab5Board.cpp deleted file mode 100644 index fcbb6f01..00000000 --- a/src/board/M5Tab5Board.cpp +++ /dev/null @@ -1,170 +0,0 @@ -#include "M5Tab5Board.h" - -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "gps/GPS.h" - -// NOTE: This is a minimal skeleton implementation that allows the firmware -// to build and run on the M5Stack Tab5 environment. Hardware-specific -// display, touch, GNSS, LoRa, and sensor wiring will be filled in gradually. - -M5Tab5Board::M5Tab5Board() : LilyGo_Display(SPI_DRIVER, false) {} - -M5Tab5Board* M5Tab5Board::getInstance() -{ - static M5Tab5Board instance; - return &instance; -} - -uint32_t M5Tab5Board::begin(uint32_t /*disable_hw_init*/) -{ - // For now, just mark display/touch as logically ready so UI can run. - display_ready_ = true; - touch_ready_ = true; - rtc_ready_ = false; - sd_ready_ = false; - gps_ready_ = false; - - // Default rotation: landscape. - rotation_ = 1; - return 0; -} - -void M5Tab5Board::setBrightness(uint8_t level) -{ - brightness_ = level; - // TODO: hook into Tab5 backlight control when integrating display driver. -} - -void M5Tab5Board::setRotation(uint8_t rotation) -{ - rotation_ = rotation; -} - -void M5Tab5Board::pushColors(uint16_t /*x1*/, uint16_t /*y1*/, uint16_t /*x2*/, uint16_t /*y2*/, uint16_t* /*color*/) -{ - // TODO: forward to Tab5 display driver (MIPI DSI) when integrated. -} - -uint16_t M5Tab5Board::width() -{ - return SCREEN_WIDTH; -} - -uint16_t M5Tab5Board::height() -{ - return SCREEN_HEIGHT; -} - -uint8_t M5Tab5Board::getPoint(int16_t* x, int16_t* y, uint8_t get_point) -{ - if (!touch_ready_ || !x || !y) - { - return 0; - } - // TODO: wire to Tab5 touch driver (GT911/ST7123). For now, report no touch. - (void)get_point; - return 0; -} - -RotaryMsg_t M5Tab5Board::getRotary() -{ - // Tab5 has no rotary encoder; always return "no movement". - RotaryMsg_t msg{}; - msg.dir = ROTARY_DIR_NONE; - msg.centerBtnPressed = false; - return msg; -} - -int M5Tab5Board::getKeyChar(char* c) -{ - // No physical keyboard on Tab5. Virtual keyboard events are injected - // directly via LVGL, so this always reports "no key". - if (c) - { - *c = '\0'; - } - return -1; -} - -// ---- LoraBoard stubs ---- - -int M5Tab5Board::transmitRadio(const uint8_t* /*data*/, size_t /*len*/) { return -1; } -int M5Tab5Board::startRadioReceive() { return -1; } -uint32_t M5Tab5Board::getRadioIrqFlags() { return 0; } -int M5Tab5Board::getRadioPacketLength(bool /*update*/) { return 0; } -int M5Tab5Board::readRadioData(uint8_t* /*buf*/, size_t /*len*/) { return 0; } -void M5Tab5Board::clearRadioIrqFlags(uint32_t /*flags*/) {} -float M5Tab5Board::getRadioRSSI() { return 0.0f; } -float M5Tab5Board::getRadioSNR() { return 0.0f; } -void M5Tab5Board::configureLoraRadio(float /*freq_mhz*/, float /*bw_khz*/, uint8_t /*sf*/, uint8_t /*cr_denom*/, - int8_t /*tx_power*/, uint16_t /*preamble_len*/, uint8_t /*sync_word*/, - uint8_t /*crc_len*/) -{ -} - -// ---- GpsBoard stubs ---- - -static GPS s_dummy_gps; - -bool M5Tab5Board::initGPS() -{ - gps_ready_ = false; - return false; -} - -GPS& M5Tab5Board::getGPS() -{ - return s_dummy_gps; -} - -void M5Tab5Board::powerControl(PowerCtrlChannel_t /*ch*/, bool /*enable*/) -{ -} - -bool M5Tab5Board::syncTimeFromGPS(uint32_t /*gps_task_interval_ms*/) -{ - return false; -} - -// ---- MotionBoard stubs ---- - -SensorBHI260AP& M5Tab5Board::getMotionSensor() -{ - // Tab5 has IMU, but we don't wire it yet. This is a stub to satisfy interface. - // Code paths that rely on motion sensor should be disabled for Tab5 via HAS_* flags. - for (;;) - { - vTaskDelay(portMAX_DELAY); - } -} - -// ---- SdBoard stubs ---- - -bool M5Tab5Board::installSD() -{ - sd_ready_ = false; - return false; -} - -void M5Tab5Board::uninstallSD() -{ -} - -bool M5Tab5Board::isCardReady() -{ - return false; -} - -// ---- Global board instance for Tab5 env ---- - -namespace -{ -M5Tab5Board& getInstanceRef() -{ - return *M5Tab5Board::getInstance(); -} -} // namespace - -M5Tab5Board& instance = getInstanceRef(); -BoardBase& board = getInstanceRef(); diff --git a/src/board/M5Tab5Board.h b/src/board/M5Tab5Board.h deleted file mode 100644 index e4e4c6db..00000000 --- a/src/board/M5Tab5Board.h +++ /dev/null @@ -1,105 +0,0 @@ -#pragma once - -#include "BoardBase.h" -#include "GpsBoard.h" -#include "LoraBoard.h" -#include "MotionBoard.h" -#include "SdBoard.h" -#include "display/DisplayInterface.h" - -// Minimal M5Stack Tab5 board integration. -// This is a skeleton that allows the firmware to build and run basic UI -// on ESP32-P4, with hardware-specific details to be filled in incrementally. - -class SensorBHI260AP; -class GPS; - -class M5Tab5Board : public BoardBase, - public LoraBoard, - public GpsBoard, - public MotionBoard, - public SdBoard, - public LilyGo_Display -{ - public: - static M5Tab5Board* getInstance(); - - // BoardBase - uint32_t begin(uint32_t disable_hw_init = 0) override; - void wakeUp() override {} - void handlePowerButton() override {} - void softwareShutdown() override {} - - void setBrightness(uint8_t level) override; - uint8_t getBrightness() override { return brightness_; } - - bool hasKeyboard() override { return false; } - void keyboardSetBrightness(uint8_t /*level*/) override {} - uint8_t keyboardGetBrightness() override { return 0; } - - bool isRTCReady() const override { return rtc_ready_; } - bool isCharging() override { return false; } - int getBatteryLevel() override { return -1; } - - bool isSDReady() const override { return sd_ready_; } - - void vibrator() override {} - void stopVibrator() override {} - - // Display / input (LilyGo_Display interface used by LV_Helper_v9) - void setRotation(uint8_t rotation) override; - uint8_t getRotation() override { return rotation_; } - void pushColors(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t* color) override; - uint16_t width() override; - uint16_t height() override; - bool useDMA() override { return false; } - bool hasTouch() override { return true; } - uint8_t getPoint(int16_t* x, int16_t* y, uint8_t get_point) override; - bool hasEncoder() override { return false; } - RotaryMsg_t getRotary() override; - int getKeyChar(char* c) override; - void feedback(void* args = NULL) override { (void)args; } - - // LoraBoard (stubs for now; real radio wiring to be added later) - bool isRadioOnline() const override { return false; } - int transmitRadio(const uint8_t* data, size_t len) override; - int startRadioReceive() override; - uint32_t getRadioIrqFlags() override; - int getRadioPacketLength(bool update) override; - int readRadioData(uint8_t* buf, size_t len) override; - void clearRadioIrqFlags(uint32_t flags) override; - float getRadioRSSI() override; - float getRadioSNR() override; - void configureLoraRadio(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr_denom, - int8_t tx_power, uint16_t preamble_len, uint8_t sync_word, - uint8_t crc_len) override; - - // GpsBoard (stubs; to be connected to Tab5 GNSS module) - bool initGPS() override; - void setGPSOnline(bool online) override { gps_ready_ = online; } - GPS& getGPS() override; - bool isGPSReady() const override { return gps_ready_; } - void powerControl(PowerCtrlChannel_t ch, bool enable) override; - bool syncTimeFromGPS(uint32_t gps_task_interval_ms = 0) override; - - // MotionBoard (Tab5 has IMU; stub until wired) - SensorBHI260AP& getMotionSensor() override; - bool isSensorReady() const override { return false; } - - // SdBoard - bool installSD() override; - void uninstallSD() override; - bool isCardReady() override; - - private: - M5Tab5Board(); - - private: - uint8_t brightness_ = DEVICE_MAX_BRIGHTNESS_LEVEL; - uint8_t rotation_ = 0; - bool display_ready_ = false; - bool touch_ready_ = false; - bool rtc_ready_ = false; - bool sd_ready_ = false; - bool gps_ready_ = false; -}; diff --git a/src/chat/infra/meshcore/meshcore_adapter.cpp b/src/chat/infra/meshcore/meshcore_adapter.cpp index 17fc7fbe..a901e5ca 100644 --- a/src/chat/infra/meshcore/meshcore_adapter.cpp +++ b/src/chat/infra/meshcore/meshcore_adapter.cpp @@ -377,6 +377,14 @@ uint32_t estimateSendTimeoutMs(size_t frame_len, size_t path_len, bool flood, static_cast(perhop * static_cast(path_len + 1)); } +uint32_t saturatingAddU32(uint32_t base, uint32_t delta) +{ + const uint64_t total = static_cast(base) + static_cast(delta); + return (total > static_cast(std::numeric_limits::max())) + ? std::numeric_limits::max() + : static_cast(total); +} + float scoreFromSnr(float snr, uint8_t sf, size_t packet_len) { static const float kSnrThreshold[] = {-7.5f, -10.0f, -12.5f, -15.0f, -17.5f, -20.0f}; @@ -1113,7 +1121,10 @@ MeshCoreAdapter::MeshCoreAdapter(LoraBoard& board) node_id_(0), self_hash_(0), last_rx_rssi_(NAN), - last_rx_snr_(NAN) + last_rx_snr_(NAN), + last_noise_floor_dbm_(0), + tx_airtime_ms_(0), + rx_airtime_ms_(0) { const uint64_t raw = ESP.getEfuseMac(); const uint8_t* mac = reinterpret_cast(&raw); @@ -1148,6 +1159,15 @@ bool MeshCoreAdapter::pollEvent(Event* out) return true; } +MeshCoreAdapter::RadioStats MeshCoreAdapter::getRadioStats() const +{ + RadioStats stats; + stats.noise_floor_dbm = last_noise_floor_dbm_; + stats.tx_airtime_ms = tx_airtime_ms_; + stats.rx_airtime_ms = rx_airtime_ms_; + return stats; +} + bool MeshCoreAdapter::ensurePeerPublicKey(const uint8_t* pubkey, size_t len, bool verified) { if (!pubkey || len != MeshCoreIdentity::kPubKeySize) @@ -2455,6 +2475,14 @@ bool MeshCoreAdapter::transmitFrameNow(const uint8_t* data, size_t len, uint32_t return false; } + const float air_ms_f = estimateLoRaAirtimeMs(len, + config_.meshcore_bw_khz, + config_.meshcore_sf, + config_.meshcore_cr); + const uint32_t air_ms = (air_ms_f > 0.0f && std::isfinite(air_ms_f)) + ? static_cast(std::lround(air_ms_f)) + : 0U; + int state = RADIOLIB_ERR_UNSUPPORTED; #if defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) state = board_.transmitRadio(data, len); @@ -2469,6 +2497,7 @@ bool MeshCoreAdapter::transmitFrameNow(const uint8_t* data, size_t len, uint32_t hasSeenSignature(packet_sig, now_ms); } last_tx_ms_ = now_ms; + tx_airtime_ms_ = saturatingAddU32(tx_airtime_ms_, air_ms); int rx_state = board_.startRadioReceive(); if (rx_state != RADIOLIB_ERR_NONE) { @@ -4067,6 +4096,10 @@ void MeshCoreAdapter::setLastRxStats(float rssi, float snr) { last_rx_rssi_ = rssi; last_rx_snr_ = snr; + if (std::isfinite(rssi) && std::isfinite(snr)) + { + last_noise_floor_dbm_ = static_cast(std::lround(rssi - snr)); + } } bool MeshCoreAdapter::isReady() const @@ -4107,6 +4140,17 @@ void MeshCoreAdapter::handleRawPacketInternal(const uint8_t* data, size_t size, return; } + const float air_ms_f = estimateLoRaAirtimeMs(size, + config_.meshcore_bw_khz, + config_.meshcore_sf, + config_.meshcore_cr); + if (air_ms_f > 0.0f && std::isfinite(air_ms_f)) + { + rx_airtime_ms_ = saturatingAddU32( + rx_airtime_ms_, + static_cast(std::lround(air_ms_f))); + } + const uint8_t header = data[0]; const uint8_t header_route = header & 0x03; const uint8_t header_type = (header >> 2) & 0x0F; diff --git a/src/chat/infra/meshcore/meshcore_adapter.h b/src/chat/infra/meshcore/meshcore_adapter.h index 21f68265..608ff133 100644 --- a/src/chat/infra/meshcore/meshcore_adapter.h +++ b/src/chat/infra/meshcore/meshcore_adapter.h @@ -149,7 +149,15 @@ class MeshCoreAdapter : public IMeshAdapter bool advert_is_new = false; }; + struct RadioStats + { + int16_t noise_floor_dbm = 0; + uint32_t tx_airtime_ms = 0; + uint32_t rx_airtime_ms = 0; + }; + bool pollEvent(Event* out); + RadioStats getRadioStats() const; bool ensurePeerPublicKey(const uint8_t* pubkey, size_t len, bool verified); @@ -281,6 +289,9 @@ class MeshCoreAdapter : public IMeshAdapter uint8_t self_hash_; float last_rx_rssi_; float last_rx_snr_; + int16_t last_noise_floor_dbm_ = 0; + uint32_t tx_airtime_ms_ = 0; + uint32_t rx_airtime_ms_ = 0; MeshCoreIdentity identity_; uint32_t last_auto_discover_ms_ = 0; uint8_t last_auto_discover_hash_ = 0; diff --git a/src/chat/infra/meshtastic/mt_adapter.cpp b/src/chat/infra/meshtastic/mt_adapter.cpp index 232cd904..03c37819 100644 --- a/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/src/chat/infra/meshtastic/mt_adapter.cpp @@ -56,6 +56,123 @@ constexpr uint8_t kLoraSyncWord = 0x2b; constexpr uint16_t kLoraPreambleLen = 16; constexpr uint8_t kBitfieldWantResponseMask = 0x02; constexpr size_t kMaxMqttProxyQueue = 12; +constexpr uint32_t kBroadcastNodeId = 0xFFFFFFFFu; +constexpr size_t kTraceRouteSlots = 8; + +bool shouldSetAirWantAck(uint32_t dest, bool want_ack) +{ + return want_ack && dest != kBroadcastNodeId; +} + +uint8_t computeHopsAway(uint8_t flags); + +void selectTraceRouteArrays(meshtastic_RouteDiscovery* route, + bool towards_destination, + pb_size_t** route_count, + uint32_t** route_nodes, + pb_size_t** snr_count, + int8_t** snr_values) +{ + if (towards_destination) + { + *route_count = &route->route_count; + *route_nodes = route->route; + *snr_count = &route->snr_towards_count; + *snr_values = route->snr_towards; + } + else + { + *route_count = &route->route_back_count; + *route_nodes = route->route_back; + *snr_count = &route->snr_back_count; + *snr_values = route->snr_back; + } +} + +int8_t encodeTraceRouteSnr(const chat::RxMeta* rx_meta) +{ + if (!rx_meta) + { + return 0; + } + + const long scaled = std::lround((static_cast(rx_meta->snr_db_x10) / 10.0f) * 4.0f); + const long min_v = static_cast(std::numeric_limits::min()); + const long max_v = static_cast(std::numeric_limits::max()); + const long clamped = (scaled < min_v) ? min_v : ((scaled > max_v) ? max_v : scaled); + return static_cast(clamped); +} + +void insertTraceRouteUnknownHops(uint8_t flags, + meshtastic_RouteDiscovery* route, + bool towards_destination) +{ + if (!route) + { + return; + } + + pb_size_t* route_count = nullptr; + uint32_t* route_nodes = nullptr; + pb_size_t* snr_count = nullptr; + int8_t* snr_values = nullptr; + selectTraceRouteArrays(route, towards_destination, &route_count, &route_nodes, &snr_count, &snr_values); + + const uint8_t hops_taken = computeHopsAway(flags); + if (hops_taken == 0xFF) + { + return; + } + + int route_missing = static_cast(hops_taken) - static_cast(*route_count); + while (route_missing-- > 0 && *route_count < kTraceRouteSlots) + { + route_nodes[*route_count] = kBroadcastNodeId; + *route_count += 1; + } + + int snr_missing = static_cast(*route_count) - static_cast(*snr_count); + while (snr_missing-- > 0 && *snr_count < kTraceRouteSlots) + { + snr_values[*snr_count] = std::numeric_limits::min(); + *snr_count += 1; + } +} + +void appendTraceRouteNodeAndSnr(meshtastic_RouteDiscovery* route, + uint32_t node_id, + const chat::RxMeta* rx_meta, + bool towards_destination, + bool snr_only) +{ + if (!route) + { + return; + } + + pb_size_t* route_count = nullptr; + uint32_t* route_nodes = nullptr; + pb_size_t* snr_count = nullptr; + int8_t* snr_values = nullptr; + selectTraceRouteArrays(route, towards_destination, &route_count, &route_nodes, &snr_count, &snr_values); + + if (*snr_count < kTraceRouteSlots) + { + snr_values[*snr_count] = encodeTraceRouteSnr(rx_meta); + *snr_count += 1; + } + + if (snr_only) + { + return; + } + + if (*route_count < kTraceRouteSlots) + { + route_nodes[*route_count] = node_id; + *route_count += 1; + } +} bool readPbString(pb_istream_t* stream, char* out, size_t out_len) { @@ -872,8 +989,9 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, size_t psk_len = (out_channel == ChannelId::SECONDARY) ? secondary_psk_len_ : primary_psk_len_; uint8_t hop_limit = config_.hop_limit; - uint32_t dest_node = (dest != 0) ? dest : 0xFFFFFFFF; - bool want_ack_flag = want_ack; + uint32_t dest_node = (dest != 0) ? dest : kBroadcastNodeId; + bool track_ack = want_ack; + bool air_want_ack = shouldSetAirWantAck(dest_node, track_ack); MessageId msg_id = (packet_id != 0) ? packet_id : next_packet_id_++; if (packet_id != 0 && packet_id >= next_packet_id_) { @@ -910,7 +1028,8 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, out_payload = pki_buf; out_len = pki_len; channel_hash = 0; // PKI channel - want_ack_flag = true; + track_ack = true; + air_want_ack = true; psk = nullptr; psk_len = 0; use_pki = true; @@ -931,7 +1050,7 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, } if (!buildWirePacket(out_payload, out_len, node_id_, msg_id, - dest_node, channel_hash, hop_limit, want_ack_flag, + dest_node, channel_hash, hop_limit, air_want_ack, psk, psk_len, wire_buffer, &wire_size)) { return false; @@ -951,16 +1070,17 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, #endif bool ok = (state == RADIOLIB_ERR_NONE); - LORA_LOG("[LORA] TX app port=%u len=%u want_resp=%u want_ack=%u ok=%d\n", + LORA_LOG("[LORA] TX app port=%u len=%u want_resp=%u air_ack=%u track_ack=%u ok=%d\n", (unsigned)portnum, (unsigned)wire_size, effective_want_response ? 1U : 0U, - want_ack_flag ? 1U : 0U, + air_want_ack ? 1U : 0U, + track_ack ? 1U : 0U, ok ? 1 : 0); if (ok) { last_tx_ms_ = now_ms; - if (want_ack_flag) + if (track_ack) { pending_ack_ms_[msg_id] = millis(); pending_ack_dest_[msg_id] = dest_node; @@ -1017,7 +1137,7 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) } meshtastic_Data data = packet.decoded; - data.dest = (packet.to != 0) ? packet.to : 0xFFFFFFFF; + data.dest = (packet.to != 0) ? packet.to : kBroadcastNodeId; data.source = node_id_; data.has_bitfield = true; @@ -1031,7 +1151,8 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) const uint32_t dest = data.dest; const uint8_t hop_limit = (packet.hop_limit > 0) ? packet.hop_limit : config_.hop_limit; - bool want_ack = packet.want_ack; + bool track_ack = packet.want_ack; + bool air_want_ack = shouldSetAirWantAck(dest, track_ack); uint8_t channel_hash = primary_channel_hash_; const uint8_t* psk = primary_psk_; size_t psk_len = primary_psk_len_; @@ -1078,7 +1199,8 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) channel_hash = 0; psk = nullptr; psk_len = 0; - want_ack = true; + track_ack = true; + air_want_ack = true; } else if (packet.channel == 1) { @@ -1095,7 +1217,7 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) uint8_t wire_buffer[512]; size_t wire_size = sizeof(wire_buffer); if (!buildWirePacket(out_payload, out_len, node_id_, msg_id, - dest, channel_hash, hop_limit, want_ack, + dest, channel_hash, hop_limit, air_want_ack, psk, psk_len, wire_buffer, &wire_size)) { last_send_error_ = meshtastic_Routing_Error_TOO_LARGE; @@ -1112,7 +1234,7 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) { last_tx_ms_ = now_ms; last_send_error_ = meshtastic_Routing_Error_NONE; - if (want_ack) + if (track_ack) { pending_ack_ms_[msg_id] = millis(); pending_ack_dest_[msg_id] = dest; @@ -2320,6 +2442,7 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP); bool is_nodeinfo_port = (decoded.portnum == meshtastic_PortNum_NODEINFO_APP); bool is_position_port = (decoded.portnum == meshtastic_PortNum_POSITION_APP); + bool is_traceroute_port = (decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP); ChannelId channel_id = (header.channel == secondary_channel_hash_) ? ChannelId::SECONDARY : ChannelId::PRIMARY; if (header.channel != 0 && header.from != node_id_) @@ -2344,6 +2467,16 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) } } + if (is_traceroute_port) + { + handleTraceRoutePacket(header, + &decoded, + &rx_meta, + channel_id, + want_ack_flag, + want_response); + } + if (want_response && to_us_or_broadcast) { if (is_nodeinfo_port) @@ -2595,7 +2728,7 @@ bool MtAdapter::sendPacket(const PendingSend& pending) std::string data_hex = toHex(data_buffer, data_size, data_size); LORA_LOG("[LORA] TX data protobuf hex: %s\n", data_hex.c_str()); - // Build full wire packet (like M5Tab5-GPS) + // Build a full Meshtastic-compatible wire packet uint8_t wire_buffer[512]; size_t wire_size = sizeof(wire_buffer); @@ -2603,8 +2736,9 @@ bool MtAdapter::sendPacket(const PendingSend& pending) uint8_t channel_hash = (channel == ChannelId::SECONDARY) ? secondary_channel_hash_ : primary_channel_hash_; uint8_t hop_limit = config_.hop_limit; - uint32_t dest = (pending.dest != 0) ? pending.dest : 0xFFFFFFFF; - bool want_ack = true; + uint32_t dest = (pending.dest != 0) ? pending.dest : kBroadcastNodeId; + bool track_ack = true; + bool air_want_ack = shouldSetAirWantAck(dest, track_ack); // Try PKI encryption for direct messages when key is known const uint8_t* payload = data_buffer; @@ -2632,7 +2766,8 @@ bool MtAdapter::sendPacket(const PendingSend& pending) payload = pki_buf; payload_len = pki_len; channel_hash = 0; // PKI channel - want_ack = true; + track_ack = true; + air_want_ack = true; use_pki = true; } @@ -2665,15 +2800,16 @@ bool MtAdapter::sendPacket(const PendingSend& pending) (unsigned long)dest); if (!buildWirePacket(payload, payload_len, from_node, pending.msg_id, - dest, channel_hash, hop_limit, want_ack, + dest, channel_hash, hop_limit, air_want_ack, psk, psk_len, wire_buffer, &wire_size)) { return false; } - LORA_LOG("[LORA] TX wire ch=0x%02X hop=%u ack=%d psk=%u wire=%u dest=%08lX\n", + LORA_LOG("[LORA] TX wire ch=0x%02X hop=%u air_ack=%d track_ack=%d psk=%u wire=%u dest=%08lX\n", channel_hash, hop_limit, - want_ack ? 1 : 0, + air_want_ack ? 1 : 0, + track_ack ? 1 : 0, (unsigned)psk_len, (unsigned)wire_size, (unsigned long)dest); @@ -2702,7 +2838,7 @@ bool MtAdapter::sendPacket(const PendingSend& pending) static_cast(channel), (unsigned)wire_size, ok ? 1 : 0); - if (ok && want_ack) + if (ok && track_ack) { pending_ack_ms_[pending.msg_id] = millis(); pending_ack_dest_[pending.msg_id] = dest; @@ -2768,8 +2904,6 @@ bool MtAdapter::sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId chan hw_model = meshtastic_HardwareModel_T_DECK; #elif defined(ARDUINO_LILYGO_TWATCH_S3) hw_model = meshtastic_HardwareModel_T_WATCH_S3; -#elif defined(ARDUINO_M5STACK_TAB5) - hw_model = meshtastic_HardwareModel_MESH_TAB; #elif defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_SX1280) hw_model = meshtastic_HardwareModel_T_LORA_PAGER; #endif @@ -2857,6 +2991,108 @@ bool MtAdapter::sendPositionTo(uint32_t dest, ChannelId channel) false); } +bool MtAdapter::sendTraceRouteResponse(uint32_t dest, + uint32_t request_id, + const meshtastic_RouteDiscovery& route, + ChannelId channel, + bool want_ack) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.to = dest; + packet.channel = (channel == ChannelId::SECONDARY) ? 1 : 0; + packet.hop_limit = config_.hop_limit; + packet.want_ack = want_ack; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded = meshtastic_Data_init_default; + packet.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP; + packet.decoded.want_response = false; + packet.decoded.request_id = request_id; + packet.decoded.has_bitfield = true; + packet.decoded.bitfield = 0; + + pb_ostream_t ostream = + pb_ostream_from_buffer(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes)); + if (!pb_encode(&ostream, meshtastic_RouteDiscovery_fields, &route)) + { + LORA_LOG("[LORA] traceroute response encode fail req=%08lX err=%s\n", + (unsigned long)request_id, + PB_GET_ERROR(&ostream)); + return false; + } + + packet.decoded.payload.size = static_cast(ostream.bytes_written); + return sendMeshPacket(packet); +} + +bool MtAdapter::handleTraceRoutePacket(const PacketHeaderWire& header, + meshtastic_Data* decoded, + const chat::RxMeta* rx_meta, + ChannelId channel, + bool want_ack_flag, + bool want_response) +{ + if (!decoded || decoded->portnum != meshtastic_PortNum_TRACEROUTE_APP || decoded->payload.size == 0) + { + return false; + } + + meshtastic_RouteDiscovery route = meshtastic_RouteDiscovery_init_zero; + pb_istream_t istream = pb_istream_from_buffer(decoded->payload.bytes, decoded->payload.size); + if (!pb_decode(&istream, meshtastic_RouteDiscovery_fields, &route)) + { + LORA_LOG("[LORA] traceroute decode fail from=%08lX err=%s\n", + (unsigned long)header.from, + PB_GET_ERROR(&istream)); + return false; + } + + const bool is_response = decoded->request_id != 0; + const bool is_broadcast = header.to == kBroadcastNodeId; + const bool to_us = header.to == node_id_; + + insertTraceRouteUnknownHops(header.flags, &route, !is_response); + appendTraceRouteNodeAndSnr(&route, node_id_, rx_meta, !is_response, to_us); + + pb_ostream_t ostream = pb_ostream_from_buffer(decoded->payload.bytes, sizeof(decoded->payload.bytes)); + if (!pb_encode(&ostream, meshtastic_RouteDiscovery_fields, &route)) + { + LORA_LOG("[LORA] traceroute re-encode fail from=%08lX err=%s\n", + (unsigned long)header.from, + PB_GET_ERROR(&ostream)); + return false; + } + decoded->payload.size = static_cast(ostream.bytes_written); + + if (!is_response && want_response && (to_us || is_broadcast)) + { + const uint8_t hop_limit = header.flags & PACKET_FLAGS_HOP_LIMIT_MASK; + const uint8_t hop_start = + (header.flags & PACKET_FLAGS_HOP_START_MASK) >> PACKET_FLAGS_HOP_START_SHIFT; + const bool ignore_broadcast_request = is_broadcast && hop_limit < hop_start; + if (ignore_broadcast_request) + { + LORA_LOG("[LORA] traceroute reply ignored broadcast req=%08lX hop=%u/%u\n", + (unsigned long)header.id, + static_cast(hop_limit), + static_cast(hop_start)); + } + else if (sendTraceRouteResponse(header.from, header.id, route, channel, want_ack_flag)) + { + LORA_LOG("[LORA] TX traceroute reply to=%08lX req=%08lX\n", + (unsigned long)header.from, + (unsigned long)header.id); + } + else + { + LORA_LOG("[LORA] TX traceroute reply fail to=%08lX req=%08lX\n", + (unsigned long)header.from, + (unsigned long)header.id); + } + } + + return true; +} + void MtAdapter::maybeBroadcastNodeInfo(uint32_t now_ms) { if (!ready_) diff --git a/src/chat/infra/meshtastic/mt_adapter.h b/src/chat/infra/meshtastic/mt_adapter.h index 8068b115..26656dd9 100644 --- a/src/chat/infra/meshtastic/mt_adapter.h +++ b/src/chat/infra/meshtastic/mt_adapter.h @@ -206,6 +206,17 @@ class MtAdapter : public chat::IMeshAdapter bool sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId channel = ChannelId::PRIMARY); bool sendPositionTo(uint32_t dest, ChannelId channel); + bool sendTraceRouteResponse(uint32_t dest, + uint32_t request_id, + const meshtastic_RouteDiscovery& route, + ChannelId channel, + bool want_ack); + bool handleTraceRoutePacket(const PacketHeaderWire& header, + meshtastic_Data* decoded, + const chat::RxMeta* rx_meta, + ChannelId channel, + bool want_ack_flag, + bool want_response); void maybeBroadcastNodeInfo(uint32_t now_ms); void configureRadio(); void initNodeIdentity(); diff --git a/src/chat/infra/meshtastic/mt_codec_pb.cpp b/src/chat/infra/meshtastic/mt_codec_pb.cpp index a8d1a83d..0e9b1372 100644 --- a/src/chat/infra/meshtastic/mt_codec_pb.cpp +++ b/src/chat/infra/meshtastic/mt_codec_pb.cpp @@ -2,7 +2,7 @@ * @file mt_codec_pb.cpp * @brief Meshtastic protocol codec implementation using protobuf * - * Based on M5Tab5-GPS implementation + * Based on this project's Meshtastic compatibility implementation */ #include "mt_codec_pb.h" @@ -24,7 +24,7 @@ bool encodeTextMessage(ChannelId channel, const std::string& text, return false; } - // Create Data message (like M5Tab5-GPS make_packet_payload) + // Create a Meshtastic Data message payload meshtastic_Data data = meshtastic_Data_init_default; data.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; data.want_response = false; @@ -71,7 +71,7 @@ bool decodeTextMessage(const uint8_t* buffer, size_t size, MeshIncomingText* out return false; } - // Decode Data message (like M5Tab5-GPS) + // Decode a Meshtastic Data message payload meshtastic_Data data = meshtastic_Data_init_default; pb_istream_t stream = pb_istream_from_buffer(buffer, size); if (!pb_decode(&stream, meshtastic_Data_fields, &data)) diff --git a/src/chat/infra/meshtastic/mt_codec_pb.h b/src/chat/infra/meshtastic/mt_codec_pb.h index da37aebe..dd63afb7 100644 --- a/src/chat/infra/meshtastic/mt_codec_pb.h +++ b/src/chat/infra/meshtastic/mt_codec_pb.h @@ -2,7 +2,7 @@ * @file mt_codec_pb.h * @brief Meshtastic protocol codec using protobuf (nanopb) * - * Uses the actual Meshtastic protobuf definitions from M5Tab5-GPS + * Uses the Meshtastic protobuf definitions generated into this project */ #pragma once @@ -12,7 +12,7 @@ #include #include -// Include generated nanopb headers from M5Tab5-GPS +// Include generated nanopb headers for the Meshtastic protocol // Note: pb.h must be included first, and paths are relative to generated/ directory #include "meshtastic/channel.pb.h" #include "meshtastic/mesh.pb.h" diff --git a/src/chat/infra/meshtastic/mt_packet_wire.h b/src/chat/infra/meshtastic/mt_packet_wire.h index e4b4f07d..2706b3c4 100644 --- a/src/chat/infra/meshtastic/mt_packet_wire.h +++ b/src/chat/infra/meshtastic/mt_packet_wire.h @@ -1,6 +1,6 @@ /** * @file mt_packet_wire.h - * @brief Meshtastic wire packet format (like M5Tab5-GPS) + * @brief Meshtastic-compatible wire packet format */ #pragma once @@ -14,7 +14,7 @@ namespace meshtastic { /** - * @brief Packet header wire format (from M5Tab5-GPS) + * @brief Packet header wire format matching Meshtastic packets * This matches Meshtastic's on-air packet format */ struct PacketHeaderWire @@ -28,7 +28,7 @@ struct PacketHeaderWire uint8_t relay_node; } __attribute__((packed)); -// Packet header flag masks (from M5Tab5-GPS) +// Packet header flag masks matching Meshtastic packets constexpr uint8_t PACKET_FLAGS_HOP_LIMIT_MASK = 0x07; constexpr uint8_t PACKET_FLAGS_WANT_ACK_MASK = 0x08; constexpr uint8_t PACKET_FLAGS_VIA_MQTT_MASK = 0x10; @@ -72,7 +72,7 @@ bool parseWirePacket(const uint8_t* buffer, size_t size, uint8_t* out_payload, size_t* out_payload_size); /** - * @brief Decrypt payload using AES-CTR (like M5Tab5-GPS) + * @brief Decrypt payload using Meshtastic-compatible AES-CTR * @param header Packet header * @param cipher Encrypted payload * @param cipher_len Cipher length diff --git a/src/gps/gps_service_api.cpp b/src/gps/gps_service_api.cpp index 0727ab44..0141149a 100644 --- a/src/gps/gps_service_api.cpp +++ b/src/gps/gps_service_api.cpp @@ -20,6 +20,16 @@ uint32_t gps_get_last_motion_ms() return GpsService::getInstance().getLastMotionMs(); } +bool gps_is_enabled() +{ + return GpsService::getInstance().isEnabled(); +} + +bool gps_is_powered() +{ + return GpsService::getInstance().isPowered(); +} + void gps_set_collection_interval(uint32_t interval_ms) { GpsService::getInstance().setCollectionInterval(interval_ms); diff --git a/src/gps/gps_service_api.h b/src/gps/gps_service_api.h index 15346b8a..0fdb80fa 100644 --- a/src/gps/gps_service_api.h +++ b/src/gps/gps_service_api.h @@ -12,6 +12,8 @@ namespace gps GpsState gps_get_data(); bool gps_get_gnss_snapshot(gps::GnssSatInfo* out, size_t max, size_t* out_count, gps::GnssStatus* status); uint32_t gps_get_last_motion_ms(); +bool gps_is_enabled(); +bool gps_is_powered(); void gps_set_collection_interval(uint32_t interval_ms); void gps_set_power_strategy(uint8_t strategy); void gps_set_gnss_config(uint8_t mode, uint8_t sat_mask); diff --git a/src/main.cpp b/src/main.cpp index 787ec45d..d52ebb35 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1476,13 +1476,6 @@ void setup() lv_obj_update_snap(panel, LV_ANIM_ON); -#if defined(ARDUINO_M5STACK_TAB5) - // On Tab5 (touch-only), create a bottom virtual key bar to emulate - // Back/Up/Down/OK keyboard navigation. - extern void ui_tab5_init_virtual_keys(lv_obj_t * root); - ui_tab5_init_virtual_keys(menu_panel); -#endif - ::ui::status::init(); #if defined(ARDUINO_T_WATCH_S3) diff --git a/src/ui/components/two_pane_layout.cpp b/src/ui/components/two_pane_layout.cpp new file mode 100644 index 00000000..8336a276 --- /dev/null +++ b/src/ui/components/two_pane_layout.cpp @@ -0,0 +1,121 @@ +#include "two_pane_layout.h" + +namespace ui::components::two_pane_layout +{ +namespace +{ + +void apply_scroll_behavior(lv_obj_t* obj, lv_scrollbar_mode_t mode) +{ + if (!obj) return; + if (mode == LV_SCROLLBAR_MODE_OFF) + { + make_non_scrollable(obj); + return; + } + lv_obj_add_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, mode); +} + +} // namespace + +void make_non_scrollable(lv_obj_t* obj) +{ + if (!obj) return; + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); +} + +void apply_base_container_style(lv_obj_t* obj) +{ + if (!obj) return; + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); +} + +lv_obj_t* create_root(lv_obj_t* parent, const RootSpec& spec) +{ + lv_obj_t* root = lv_obj_create(parent); + lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_row(root, spec.pad_row, 0); + lv_obj_set_style_pad_all(root, 0, 0); + apply_base_container_style(root); + make_non_scrollable(root); + return root; +} + +lv_obj_t* create_header_container(lv_obj_t* parent, const HeaderSpec& spec) +{ + lv_obj_t* header = lv_obj_create(parent); + lv_obj_set_size(header, LV_PCT(100), spec.height); + lv_obj_set_style_bg_opa(header, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(header, lv_color_hex(spec.bg_hex), 0); + lv_obj_set_style_pad_all(header, spec.pad_all, 0); + apply_base_container_style(header); + make_non_scrollable(header); + return header; +} + +lv_obj_t* create_content_row(lv_obj_t* parent, const ContentSpec& spec) +{ + lv_obj_t* content = lv_obj_create(parent); + lv_obj_set_width(content, LV_PCT(100)); + lv_obj_set_height(content, 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(content, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_left(content, spec.pad_left, 0); + lv_obj_set_style_pad_right(content, spec.pad_right, 0); + lv_obj_set_style_pad_top(content, spec.pad_top, 0); + lv_obj_set_style_pad_bottom(content, spec.pad_bottom, 0); + apply_base_container_style(content); + make_non_scrollable(content); + return content; +} + +lv_obj_t* create_side_panel(lv_obj_t* parent, const SidePanelSpec& spec) +{ + lv_obj_t* panel = lv_obj_create(parent); + lv_obj_set_width(panel, spec.width); + lv_obj_set_height(panel, LV_PCT(100)); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(panel, spec.pad_row, LV_PART_MAIN); + lv_obj_set_style_margin_left(panel, spec.margin_left, LV_PART_MAIN); + lv_obj_set_style_margin_right(panel, spec.margin_right, LV_PART_MAIN); + apply_base_container_style(panel); + apply_scroll_behavior(panel, spec.scrollbar_mode); + return panel; +} + +lv_obj_t* create_main_panel(lv_obj_t* parent, const MainPanelSpec& spec) +{ + lv_obj_t* panel = lv_obj_create(parent); + lv_obj_set_height(panel, LV_PCT(100)); + lv_obj_set_width(panel, 0); + lv_obj_set_flex_grow(panel, 1); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(panel, spec.pad_all, 0); + lv_obj_set_style_pad_row(panel, spec.pad_row, 0); + lv_obj_set_style_pad_left(panel, spec.pad_left, 0); + lv_obj_set_style_pad_right(panel, spec.pad_right, 0); + lv_obj_set_style_pad_top(panel, spec.pad_top, 0); + lv_obj_set_style_pad_bottom(panel, spec.pad_bottom, 0); + lv_obj_set_style_margin_left(panel, spec.margin_left, LV_PART_MAIN); + lv_obj_set_style_margin_right(panel, spec.margin_right, LV_PART_MAIN); + lv_obj_set_style_margin_bottom(panel, spec.margin_bottom, LV_PART_MAIN); + apply_base_container_style(panel); + apply_scroll_behavior(panel, spec.scrollbar_mode); + return panel; +} + +} // namespace ui::components::two_pane_layout diff --git a/src/ui/components/two_pane_layout.h b/src/ui/components/two_pane_layout.h new file mode 100644 index 00000000..b3571f18 --- /dev/null +++ b/src/ui/components/two_pane_layout.h @@ -0,0 +1,59 @@ +#pragma once + +#include "lvgl.h" +#include "two_pane_styles.h" + +namespace ui::components::two_pane_layout +{ + +struct RootSpec +{ + int pad_row = 3; +}; + +struct HeaderSpec +{ + lv_coord_t height = 0; + uint32_t bg_hex = two_pane_styles::kSidePanelBg; + int pad_all = 0; +}; + +struct ContentSpec +{ + int pad_left = 0; + int pad_right = 0; + int pad_top = 0; + int pad_bottom = 0; +}; + +struct SidePanelSpec +{ + lv_coord_t width = 80; + int pad_row = 3; + int margin_left = 0; + int margin_right = 0; + lv_scrollbar_mode_t scrollbar_mode = LV_SCROLLBAR_MODE_OFF; +}; + +struct MainPanelSpec +{ + int pad_all = 0; + int pad_row = 3; + int pad_left = 0; + int pad_right = 0; + int pad_top = 0; + int pad_bottom = 0; + int margin_left = 0; + int margin_right = 0; + int margin_bottom = 0; + lv_scrollbar_mode_t scrollbar_mode = LV_SCROLLBAR_MODE_OFF; +}; + +void make_non_scrollable(lv_obj_t* obj); +lv_obj_t* create_root(lv_obj_t* parent, const RootSpec& spec = {}); +lv_obj_t* create_header_container(lv_obj_t* parent, const HeaderSpec& spec); +lv_obj_t* create_content_row(lv_obj_t* parent, const ContentSpec& spec = {}); +lv_obj_t* create_side_panel(lv_obj_t* parent, const SidePanelSpec& spec = {}); +lv_obj_t* create_main_panel(lv_obj_t* parent, const MainPanelSpec& spec = {}); + +} // namespace ui::components::two_pane_layout diff --git a/src/ui/components/two_pane_nav.cpp b/src/ui/components/two_pane_nav.cpp new file mode 100644 index 00000000..096f9121 --- /dev/null +++ b/src/ui/components/two_pane_nav.cpp @@ -0,0 +1,488 @@ +#include "two_pane_nav.h" + +#include "../ui_common.h" + +namespace ui +{ +namespace components +{ +namespace two_pane_nav +{ +namespace +{ + +static bool is_valid(lv_obj_t* obj) +{ + return obj && lv_obj_is_valid(obj); +} + +static bool is_visible(lv_obj_t* obj) +{ + if (!is_valid(obj)) return false; + for (lv_obj_t* cur = obj; cur != nullptr; cur = lv_obj_get_parent(cur)) + { + if (lv_obj_has_flag(cur, LV_OBJ_FLAG_HIDDEN)) + { + return false; + } + } + return true; +} + +static bool binding_alive(const Binding* binding) +{ + if (!binding || !binding->bound) return false; + if (!binding->adapter.is_alive) return true; + return binding->adapter.is_alive(binding->adapter.ctx); +} + +static lv_indev_t* find_encoder_indev() +{ + lv_indev_t* indev = nullptr; + while ((indev = lv_indev_get_next(indev)) != nullptr) + { + if (lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER) + { + return indev; + } + } + return nullptr; +} + +static bool is_encoder_active() +{ + lv_indev_t* indev = lv_indev_get_act(); + return indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; +} + +static void group_clear_all(lv_group_t* group) +{ + if (!group) return; + lv_group_remove_all_objs(group); +} + +static void root_key_event_cb(lv_event_t* e); + +static void attach_key_handler(Binding* binding, lv_obj_t* obj) +{ + if (!binding || !binding->group || !is_valid(obj)) return; + lv_obj_remove_event_cb(obj, root_key_event_cb); + lv_obj_add_event_cb(obj, root_key_event_cb, LV_EVENT_KEY, binding); +} + +static void group_add_if_visible(Binding* binding, lv_obj_t* obj) +{ + if (!binding || !binding->group || !is_visible(obj)) return; + lv_group_add_obj(binding->group, obj); + attach_key_handler(binding, obj); +} + +static void focus_first_valid(Binding* binding, lv_obj_t* obj) +{ + if (!binding || !binding->group || !is_valid(obj)) return; + lv_group_focus_obj(obj); + if (is_valid(obj)) + { + lv_obj_scroll_to_view(obj, LV_ANIM_OFF); + } +} + +static lv_obj_t* get_top_back_button(Binding* binding) +{ + return (binding && binding->adapter.get_top_back_button) + ? binding->adapter.get_top_back_button(binding->adapter.ctx) + : nullptr; +} + +static lv_obj_t* get_filter_button(Binding* binding, size_t index) +{ + return (binding && binding->adapter.get_filter_button) + ? binding->adapter.get_filter_button(binding->adapter.ctx, index) + : nullptr; +} + +static lv_obj_t* get_list_button(Binding* binding, size_t index) +{ + return (binding && binding->adapter.get_list_button) + ? binding->adapter.get_list_button(binding->adapter.ctx, index) + : nullptr; +} + +static lv_obj_t* get_list_back_button(Binding* binding) +{ + return (binding && binding->adapter.get_list_back_button) + ? binding->adapter.get_list_back_button(binding->adapter.ctx) + : nullptr; +} + +static void notify_column_changed(Binding* binding) +{ + if (!binding || !binding->adapter.on_column_changed) + { + return; + } + binding->adapter.on_column_changed(binding->adapter.ctx, binding->column); +} + +static void set_column(Binding* binding, FocusColumn column) +{ + if (!binding) + { + return; + } + if (binding->column == column) + { + return; + } + binding->column = column; + notify_column_changed(binding); +} + +static int get_preferred_filter_index(Binding* binding) +{ + return (binding && binding->adapter.get_preferred_filter_index) + ? binding->adapter.get_preferred_filter_index(binding->adapter.ctx) + : -1; +} + +static int get_preferred_list_index(Binding* binding) +{ + return (binding && binding->adapter.get_preferred_list_index) + ? binding->adapter.get_preferred_list_index(binding->adapter.ctx) + : -1; +} + +static size_t get_filter_count(Binding* binding) +{ + return (binding && binding->adapter.get_filter_count) + ? binding->adapter.get_filter_count(binding->adapter.ctx) + : 0; +} + +static size_t get_list_count(Binding* binding) +{ + return (binding && binding->adapter.get_list_count) + ? binding->adapter.get_list_count(binding->adapter.ctx) + : 0; +} + +static void add_top_back(Binding* binding, BackPlacement placement, BackPlacement desired) +{ + if (placement != desired) return; + group_add_if_visible(binding, get_top_back_button(binding)); +} + +static lv_obj_t* first_visible_filter_button(Binding* binding) +{ + const size_t count = get_filter_count(binding); + for (size_t index = 0; index < count; ++index) + { + if (lv_obj_t* btn = get_filter_button(binding, index)) + { + if (is_visible(btn)) + { + return btn; + } + } + } + return nullptr; +} + +static lv_obj_t* first_visible_list_button(Binding* binding) +{ + const size_t count = get_list_count(binding); + for (size_t index = 0; index < count; ++index) + { + if (lv_obj_t* btn = get_list_button(binding, index)) + { + if (is_visible(btn)) + { + return btn; + } + } + } + return nullptr; +} + +static void bind_filter_column(Binding* binding) +{ + if (!binding || !binding->group) return; + + lv_group_focus_freeze(binding->group, true); + group_clear_all(binding->group); + lv_group_set_editing(binding->group, false); + + add_top_back(binding, binding->adapter.filter_top_back_placement, BackPlacement::Leading); + + const size_t count = get_filter_count(binding); + for (size_t index = 0; index < count; ++index) + { + group_add_if_visible(binding, get_filter_button(binding, index)); + } + + add_top_back(binding, binding->adapter.filter_top_back_placement, BackPlacement::Trailing); + lv_group_focus_freeze(binding->group, false); + + lv_obj_t* target = nullptr; + const int preferred = get_preferred_filter_index(binding); + if (preferred >= 0 && static_cast(preferred) < count) + { + target = get_filter_button(binding, static_cast(preferred)); + if (!is_visible(target)) + { + target = nullptr; + } + } + if (!target) + { + target = first_visible_filter_button(binding); + } + if (!target) + { + target = get_top_back_button(binding); + if (!is_visible(target)) + { + target = nullptr; + } + } + if (target) + { + focus_first_valid(binding, target); + } +} + +static void bind_list_column(Binding* binding) +{ + if (!binding || !binding->group) return; + + lv_group_focus_freeze(binding->group, true); + group_clear_all(binding->group); + lv_group_set_editing(binding->group, false); + + add_top_back(binding, binding->adapter.list_top_back_placement, BackPlacement::Leading); + + const size_t count = get_list_count(binding); + for (size_t index = 0; index < count; ++index) + { + group_add_if_visible(binding, get_list_button(binding, index)); + } + + if (lv_obj_t* back = get_list_back_button(binding)) + { + group_add_if_visible(binding, back); + } + + add_top_back(binding, binding->adapter.list_top_back_placement, BackPlacement::Trailing); + lv_group_focus_freeze(binding->group, false); + + lv_obj_t* target = nullptr; + const int preferred = get_preferred_list_index(binding); + if (preferred >= 0 && static_cast(preferred) < count) + { + target = get_list_button(binding, static_cast(preferred)); + if (!is_visible(target)) + { + target = nullptr; + } + } + if (!target) + { + target = first_visible_list_button(binding); + } + if (!target) + { + target = get_list_back_button(binding); + if (!is_visible(target)) + { + target = nullptr; + } + } + if (!target) + { + target = get_top_back_button(binding); + if (!is_visible(target)) + { + target = nullptr; + } + } + if (target) + { + focus_first_valid(binding, target); + } +} + +static void rebind_by_column(Binding* binding) +{ + if (!binding) return; + if (binding->column == FocusColumn::Filter) + { + bind_filter_column(binding); + } + else + { + bind_list_column(binding); + } +} + +static void root_key_event_cb(lv_event_t* e) +{ + auto* binding = static_cast(lv_event_get_user_data(e)); + if (!binding_alive(binding)) return; + + const uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_BACKSPACE) + { + if (lv_obj_t* back = get_top_back_button(binding)) + { + lv_obj_send_event(back, LV_EVENT_CLICKED, nullptr); + } + return; + } + + if (!is_encoder_active()) return; + + if (key == LV_KEY_ESC) + { + set_column(binding, FocusColumn::Filter); + rebind_by_column(binding); + return; + } + + if (key != LV_KEY_ENTER) return; + + lv_obj_t* focused = binding->group ? lv_group_get_focused(binding->group) : nullptr; + if (!focused) return; + + if (binding->column == FocusColumn::Filter) + { + if (focused == get_top_back_button(binding) && is_visible(focused)) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return; + } + + set_column(binding, FocusColumn::List); + rebind_by_column(binding); + return; + } + + if (binding->column == FocusColumn::List) + { + if (focused == get_list_back_button(binding) && is_visible(focused)) + { + set_column(binding, FocusColumn::Filter); + rebind_by_column(binding); + return; + } + + if (focused == get_top_back_button(binding) && is_visible(focused)) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return; + } + + if (binding->adapter.handle_list_enter && + binding->adapter.handle_list_enter(binding->adapter.ctx, focused)) + { + return; + } + } +} + +} // namespace + +void init(Binding* binding, const Adapter& adapter) +{ + if (!binding) return; + if (binding->bound) + { + cleanup(binding); + } + + binding->adapter = adapter; + binding->group = lv_group_create(); + binding->prev_group = lv_group_get_default(); + binding->encoder = find_encoder_indev(); + binding->column = FocusColumn::Filter; + binding->bound = true; + + set_default_group(nullptr); + set_default_group(binding->group); + lv_group_set_default(nullptr); + lv_group_set_editing(binding->group, false); + notify_column_changed(binding); + rebind_by_column(binding); + + if (lv_obj_t* key_target = adapter.get_key_target ? adapter.get_key_target(adapter.ctx) : nullptr) + { + if (is_valid(key_target)) + { + lv_obj_add_event_cb(key_target, root_key_event_cb, LV_EVENT_KEY, binding); + } + } +} + +void cleanup(Binding* binding) +{ + if (!binding || !binding->bound) return; + + if (binding->group) + { + set_default_group(nullptr); + lv_group_del(binding->group); + binding->group = nullptr; + } + if (binding->prev_group) + { + set_default_group(binding->prev_group); + } + + binding->prev_group = nullptr; + binding->encoder = nullptr; + binding->bound = false; + binding->adapter = Adapter{}; + binding->column = FocusColumn::Filter; +} + +void on_ui_refreshed(Binding* binding) +{ + if (!binding_alive(binding) || !binding->group) return; + lv_group_set_editing(binding->group, false); + + if (binding->column == FocusColumn::List) + { + rebind_by_column(binding); + return; + } + + lv_obj_t* focused = lv_group_get_focused(binding->group); + if (is_visible(focused)) + { + return; + } + + bind_filter_column(binding); +} + +void focus_filter(Binding* binding) +{ + if (!binding_alive(binding) || !binding->group) return; + set_column(binding, FocusColumn::Filter); + rebind_by_column(binding); +} + +void focus_list(Binding* binding) +{ + if (!binding_alive(binding) || !binding->group) return; + set_column(binding, FocusColumn::List); + rebind_by_column(binding); +} + +lv_group_t* get_group(const Binding* binding) +{ + return binding ? binding->group : nullptr; +} + +} // namespace two_pane_nav +} // namespace components +} // namespace ui diff --git a/src/ui/components/two_pane_nav.h b/src/ui/components/two_pane_nav.h new file mode 100644 index 00000000..a4868c34 --- /dev/null +++ b/src/ui/components/two_pane_nav.h @@ -0,0 +1,69 @@ +#pragma once + +#include "lvgl.h" +#include +#include + +namespace ui +{ +namespace components +{ +namespace two_pane_nav +{ + +enum class FocusColumn : uint8_t +{ + Filter = 0, + List = 1, +}; + +enum class BackPlacement : uint8_t +{ + None = 0, + Leading, + Trailing, +}; + +struct Adapter +{ + void* ctx = nullptr; + bool (*is_alive)(void* ctx) = nullptr; + lv_obj_t* (*get_key_target)(void* ctx) = nullptr; + lv_obj_t* (*get_top_back_button)(void* ctx) = nullptr; + + size_t (*get_filter_count)(void* ctx) = nullptr; + lv_obj_t* (*get_filter_button)(void* ctx, size_t index) = nullptr; + int (*get_preferred_filter_index)(void* ctx) = nullptr; + + size_t (*get_list_count)(void* ctx) = nullptr; + lv_obj_t* (*get_list_button)(void* ctx, size_t index) = nullptr; + int (*get_preferred_list_index)(void* ctx) = nullptr; + lv_obj_t* (*get_list_back_button)(void* ctx) = nullptr; + + bool (*handle_list_enter)(void* ctx, lv_obj_t* focused) = nullptr; + void (*on_column_changed)(void* ctx, FocusColumn column) = nullptr; + + BackPlacement filter_top_back_placement = BackPlacement::Leading; + BackPlacement list_top_back_placement = BackPlacement::None; +}; + +struct Binding +{ + Adapter adapter{}; + lv_group_t* group = nullptr; + lv_group_t* prev_group = nullptr; + lv_indev_t* encoder = nullptr; + FocusColumn column = FocusColumn::Filter; + bool bound = false; +}; + +void init(Binding* binding, const Adapter& adapter); +void cleanup(Binding* binding); +void on_ui_refreshed(Binding* binding); +void focus_filter(Binding* binding); +void focus_list(Binding* binding); +lv_group_t* get_group(const Binding* binding); + +} // namespace two_pane_nav +} // namespace components +} // namespace ui diff --git a/src/ui/components/two_pane_styles.cpp b/src/ui/components/two_pane_styles.cpp new file mode 100644 index 00000000..d2079eee --- /dev/null +++ b/src/ui/components/two_pane_styles.cpp @@ -0,0 +1,150 @@ +#include "two_pane_styles.h" + +namespace ui::components::two_pane_styles +{ +namespace +{ + +bool s_inited = false; + +lv_style_t s_panel_side; +lv_style_t s_panel_main; +lv_style_t s_container_main; + +lv_style_t s_btn_basic; +lv_style_t s_btn_filter_checked; +lv_style_t s_btn_filter_focused; + +lv_style_t s_item_base; +lv_style_t s_item_focused; + +lv_style_t s_label_primary; +lv_style_t s_label_muted; +lv_style_t s_label_accent; + +} // namespace + +void init_once() +{ + if (s_inited) return; + s_inited = true; + + lv_style_init(&s_panel_side); + lv_style_set_bg_opa(&s_panel_side, LV_OPA_COVER); + lv_style_set_bg_color(&s_panel_side, lv_color_hex(kSidePanelBg)); + lv_style_set_border_width(&s_panel_side, 0); + lv_style_set_pad_all(&s_panel_side, 3); + lv_style_set_radius(&s_panel_side, 0); + + lv_style_init(&s_panel_main); + lv_style_set_bg_opa(&s_panel_main, LV_OPA_COVER); + lv_style_set_bg_color(&s_panel_main, lv_color_hex(kMainPanelBg)); + lv_style_set_border_width(&s_panel_main, 0); + lv_style_set_pad_all(&s_panel_main, 3); + lv_style_set_radius(&s_panel_main, 0); + + lv_style_init(&s_container_main); + lv_style_set_bg_opa(&s_container_main, LV_OPA_COVER); + lv_style_set_bg_color(&s_container_main, lv_color_hex(kMainPanelBg)); + lv_style_set_border_width(&s_container_main, 0); + lv_style_set_pad_all(&s_container_main, 3); + lv_style_set_radius(&s_container_main, 0); + + lv_style_init(&s_btn_basic); + lv_style_set_bg_opa(&s_btn_basic, LV_OPA_COVER); + lv_style_set_bg_color(&s_btn_basic, lv_color_hex(kMainPanelBg)); + lv_style_set_border_width(&s_btn_basic, 1); + lv_style_set_border_color(&s_btn_basic, lv_color_hex(kBorder)); + lv_style_set_radius(&s_btn_basic, 12); + lv_style_set_text_color(&s_btn_basic, lv_color_hex(kTextPrimary)); + + lv_style_init(&s_btn_filter_checked); + lv_style_set_bg_opa(&s_btn_filter_checked, LV_OPA_COVER); + lv_style_set_bg_color(&s_btn_filter_checked, lv_color_hex(kAccent)); + + lv_style_init(&s_btn_filter_focused); + lv_style_set_bg_opa(&s_btn_filter_focused, LV_OPA_COVER); + lv_style_set_bg_color(&s_btn_filter_focused, lv_color_hex(kAccent)); + + lv_style_init(&s_item_base); + lv_style_set_bg_opa(&s_item_base, LV_OPA_COVER); + lv_style_set_bg_color(&s_item_base, lv_color_hex(kMainPanelBg)); + lv_style_set_border_width(&s_item_base, 1); + lv_style_set_border_color(&s_item_base, lv_color_hex(kBorder)); + lv_style_set_radius(&s_item_base, 6); + + lv_style_init(&s_item_focused); + lv_style_set_bg_opa(&s_item_focused, LV_OPA_COVER); + lv_style_set_bg_color(&s_item_focused, lv_color_hex(kAccent)); + lv_style_set_outline_width(&s_item_focused, 0); + + lv_style_init(&s_label_primary); + lv_style_set_text_color(&s_label_primary, lv_color_hex(kTextPrimary)); + + lv_style_init(&s_label_muted); + lv_style_set_text_color(&s_label_muted, lv_color_hex(kTextMuted)); + + lv_style_init(&s_label_accent); + lv_style_set_text_color(&s_label_accent, lv_color_hex(kAccent)); +} + +void apply_panel_side(lv_obj_t* obj) +{ + init_once(); + lv_obj_add_style(obj, &s_panel_side, 0); +} + +void apply_panel_main(lv_obj_t* obj) +{ + init_once(); + lv_obj_add_style(obj, &s_panel_main, 0); +} + +void apply_container_main(lv_obj_t* obj) +{ + init_once(); + lv_obj_add_style(obj, &s_container_main, 0); +} + +void apply_btn_basic(lv_obj_t* btn) +{ + init_once(); + lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); +} + +void apply_btn_filter(lv_obj_t* btn) +{ + init_once(); + lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); + lv_obj_add_style(btn, &s_btn_filter_checked, LV_PART_MAIN | LV_STATE_CHECKED); + lv_obj_add_style(btn, &s_btn_filter_focused, LV_PART_MAIN | LV_STATE_FOCUSED); + lv_obj_add_style(btn, &s_btn_filter_focused, LV_PART_MAIN | LV_STATE_FOCUS_KEY); +} + +void apply_list_item(lv_obj_t* item) +{ + init_once(); + lv_obj_add_style(item, &s_item_base, LV_PART_MAIN); + lv_obj_add_style(item, &s_item_focused, LV_PART_MAIN | LV_STATE_FOCUSED); + lv_obj_add_style(item, &s_item_focused, LV_PART_MAIN | LV_STATE_FOCUS_KEY); +} + +void apply_label_primary(lv_obj_t* label) +{ + init_once(); + lv_obj_add_style(label, &s_label_primary, 0); +} + +void apply_label_muted(lv_obj_t* label) +{ + init_once(); + lv_obj_add_style(label, &s_label_muted, 0); +} + +void apply_label_accent(lv_obj_t* label) +{ + init_once(); + lv_obj_add_style(label, &s_label_accent, 0); +} + +} // namespace ui::components::two_pane_styles diff --git a/src/ui/components/two_pane_styles.h b/src/ui/components/two_pane_styles.h new file mode 100644 index 00000000..a141d842 --- /dev/null +++ b/src/ui/components/two_pane_styles.h @@ -0,0 +1,29 @@ +#pragma once + +#include "lvgl.h" + +namespace ui::components::two_pane_styles +{ + +static constexpr uint32_t kSidePanelBg = 0xF6E6C6; +static constexpr uint32_t kMainPanelBg = 0xFAF0D8; +static constexpr uint32_t kBorder = 0xE7C98F; +static constexpr uint32_t kAccent = 0xEBA341; +static constexpr uint32_t kTextPrimary = 0x6B4A1E; +static constexpr uint32_t kTextMuted = 0x8A6A3A; + +void init_once(); + +void apply_panel_side(lv_obj_t* obj); +void apply_panel_main(lv_obj_t* obj); +void apply_container_main(lv_obj_t* obj); + +void apply_btn_basic(lv_obj_t* btn); +void apply_btn_filter(lv_obj_t* btn); +void apply_list_item(lv_obj_t* item); + +void apply_label_primary(lv_obj_t* label); +void apply_label_muted(lv_obj_t* label); +void apply_label_accent(lv_obj_t* label); + +} // namespace ui::components::two_pane_styles diff --git a/src/ui/screens/chat/chat_message_list_input.cpp b/src/ui/screens/chat/chat_message_list_input.cpp index f48c73f7..3961931d 100644 --- a/src/ui/screens/chat/chat_message_list_input.cpp +++ b/src/ui/screens/chat/chat_message_list_input.cpp @@ -1,349 +1,154 @@ #if !defined(ARDUINO_T_WATCH_S3) -/** - * @file chat_message_list_input.cpp - * @brief Input handling for ChatMessageListScreen (explicit layer) - * - * Current policy: - * - Rotary focuses filter buttons (Direct/Broadcast) or list items. - * - Press in filter column moves to list (or back button exits). - * - Press in list column returns to filter when Back is focused. - * - Back/ESC returns to filter column. - */ - #include "chat_message_list_input.h" -#include "../ui_common.h" + #include "chat_message_list_components.h" -#include // keep Arduino first if your build requires it -#define CHAT_INPUT_DEBUG 0 -#if CHAT_INPUT_DEBUG -#define CHAT_INPUT_LOG(...) Serial.printf(__VA_ARGS__) -#else -#define CHAT_INPUT_LOG(...) -#endif - -namespace chat::ui::message_list::input +namespace chat +{ +namespace ui +{ +namespace message_list +{ +namespace input { - namespace { -static bool is_encoder_active() +using BackPlacement = ::ui::components::two_pane_nav::BackPlacement; +using Adapter = ::ui::components::two_pane_nav::Adapter; + +static bool screen_alive(void* ctx) { - lv_indev_t* indev = lv_indev_get_act(); - return indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; + auto* screen = static_cast(ctx); + return screen && screen->isAlive(); } -static void group_clear_all(lv_group_t* g) +static lv_obj_t* get_key_target(void* ctx) { - if (!g) return; - lv_group_remove_all_objs(g); + auto* screen = static_cast(ctx); + return screen ? screen->getObj() : nullptr; } -static bool screen_alive(const Binding* binding) +static lv_obj_t* get_top_back_button(void* ctx) { - return binding && binding->screen && binding->screen->isAlive(); + auto* screen = static_cast(ctx); + return screen ? screen->getBackButton() : nullptr; } -static void focus_first_valid(Binding* binding, lv_obj_t* obj) +static size_t get_filter_count(void* /*ctx*/) { - if (!binding || !binding->group || !obj || !lv_obj_is_valid(obj)) return; - lv_group_focus_obj(obj); + return 3; } -static void root_key_event_cb(lv_event_t* e); - -static void group_add_if_valid(Binding* binding, lv_obj_t* obj) +static lv_obj_t* get_filter_button(void* ctx, size_t index) { - if (!binding || !binding->group || !obj || !lv_obj_is_valid(obj)) return; - lv_group_add_obj(binding->group, obj); - lv_obj_remove_event_cb(obj, root_key_event_cb); - lv_obj_add_event_cb(obj, root_key_event_cb, LV_EVENT_KEY, binding); -} - -static bool is_visible(lv_obj_t* obj) -{ - return obj && lv_obj_is_valid(obj) && !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN); -} - -static void clear_list_focus_states(Binding* binding) -{ - if (!binding || !binding->screen) return; - lv_state_t clear_mask = (lv_state_t)(LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY); - size_t count = binding->screen->getItemCount(); - for (size_t i = 0; i < count; ++i) + auto* screen = static_cast(ctx); + if (!screen) return nullptr; + switch (index) { - if (lv_obj_t* btn = binding->screen->getItemButton(i)) - { - if (lv_obj_is_valid(btn)) - { - lv_obj_clear_state(btn, clear_mask); - } - } - } - if (lv_obj_t* back = binding->screen->getListBackButton()) - { - if (lv_obj_is_valid(back)) - { - lv_obj_clear_state(back, clear_mask); - } + case 0: + return screen->getDirectButton(); + case 1: + return screen->getBroadcastButton(); + case 2: + return screen->getTeamButton(); + default: + return nullptr; } } -static void bind_filter_column(Binding* binding) +static int get_preferred_filter_index(void* ctx) { - if (!binding || !binding->group || !binding->screen) return; - lv_group_focus_freeze(binding->group, true); - group_clear_all(binding->group); - clear_list_focus_states(binding); - - if (lv_obj_t* back = binding->screen->getBackButton()) + auto* screen = static_cast(ctx); + if (!screen) return -1; + if (lv_obj_t* btn = screen->getDirectButton()) { - group_add_if_valid(binding, back); + if (lv_obj_has_state(btn, LV_STATE_CHECKED)) return 0; } - if (lv_obj_t* direct = binding->screen->getDirectButton()) + if (lv_obj_t* btn = screen->getBroadcastButton()) { - group_add_if_valid(binding, direct); + if (lv_obj_has_state(btn, LV_STATE_CHECKED)) return 1; } - if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) + if (lv_obj_t* btn = screen->getTeamButton()) { - group_add_if_valid(binding, broadcast); - } - if (lv_obj_t* team = binding->screen->getTeamButton()) - { - if (is_visible(team)) - { - group_add_if_valid(binding, team); - } - } - - lv_group_focus_freeze(binding->group, false); - - if (lv_obj_t* direct = binding->screen->getDirectButton()) - { - if (lv_obj_has_state(direct, LV_STATE_CHECKED)) - { - focus_first_valid(binding, direct); - return; - } - } - if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) - { - if (lv_obj_has_state(broadcast, LV_STATE_CHECKED)) - { - focus_first_valid(binding, broadcast); - return; - } - } - if (lv_obj_t* team = binding->screen->getTeamButton()) - { - if (is_visible(team) && lv_obj_has_state(team, LV_STATE_CHECKED)) - { - focus_first_valid(binding, team); - return; - } - } - - if (lv_obj_t* direct = binding->screen->getDirectButton()) - { - focus_first_valid(binding, direct); - } - else if (lv_obj_t* broadcast = binding->screen->getBroadcastButton()) - { - focus_first_valid(binding, broadcast); - } - else if (lv_obj_t* team = binding->screen->getTeamButton()) - { - if (is_visible(team)) - { - focus_first_valid(binding, team); - } - } - else if (lv_obj_t* back = binding->screen->getBackButton()) - { - focus_first_valid(binding, back); + if (lv_obj_has_state(btn, LV_STATE_CHECKED)) return 2; } + return 0; } -static void bind_list_column(Binding* binding) +static size_t get_list_count(void* ctx) { - if (!binding || !binding->group || !binding->screen) return; - lv_group_focus_freeze(binding->group, true); - group_clear_all(binding->group); - - size_t count = binding->screen->getItemCount(); - for (size_t i = 0; i < count; ++i) - { - if (lv_obj_t* btn = binding->screen->getItemButton(i)) - { - group_add_if_valid(binding, btn); - } - } - if (lv_obj_t* back = binding->screen->getListBackButton()) - { - group_add_if_valid(binding, back); - } - - lv_group_focus_freeze(binding->group, false); - - if (count > 0) - { - int selected = binding->screen->getSelectedIndex(); - if (selected >= 0 && static_cast(selected) < count) - { - focus_first_valid(binding, binding->screen->getItemButton(static_cast(selected))); - } - else - { - focus_first_valid(binding, binding->screen->getItemButton(0)); - } - } - else if (lv_obj_t* back = binding->screen->getListBackButton()) - { - focus_first_valid(binding, back); - } - else - { - binding->col = FocusColumn::Filter; - bind_filter_column(binding); - } + auto* screen = static_cast(ctx); + return screen ? screen->getItemCount() : 0; } -static void rebind_by_column(Binding* binding) +static lv_obj_t* get_list_button(void* ctx, size_t index) { - if (!binding) return; - if (binding->col == FocusColumn::Filter) - { - bind_filter_column(binding); - } - else - { - bind_list_column(binding); - } + auto* screen = static_cast(ctx); + return screen ? screen->getItemButton(index) : nullptr; } -static void root_key_event_cb(lv_event_t* e) +static int get_preferred_list_index(void* ctx) { - auto* binding = static_cast(lv_event_get_user_data(e)); - if (!screen_alive(binding)) return; + auto* screen = static_cast(ctx); + return screen ? screen->getSelectedIndex() : -1; +} - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_BACKSPACE) - { - if (binding->screen && binding->screen->getBackButton()) - { - lv_obj_send_event(binding->screen->getBackButton(), LV_EVENT_CLICKED, nullptr); - } - return; - } - if (!is_encoder_active()) return; - if (key == LV_KEY_ESC) - { - binding->col = FocusColumn::Filter; - rebind_by_column(binding); - return; - } +static lv_obj_t* get_list_back_button(void* ctx) +{ + auto* screen = static_cast(ctx); + return screen ? screen->getListBackButton() : nullptr; +} - if (key != LV_KEY_ENTER) return; - - lv_obj_t* focused = binding->group ? lv_group_get_focused(binding->group) : nullptr; - if (!focused || !binding->screen) return; - - if (binding->col == FocusColumn::Filter) - { - if (focused == binding->screen->getBackButton()) - { - lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); - return; - } - binding->col = FocusColumn::List; - rebind_by_column(binding); - return; - } - - if (binding->col == FocusColumn::List) - { - if (focused == binding->screen->getListBackButton()) - { - binding->col = FocusColumn::Filter; - rebind_by_column(binding); - return; - } - } +static Adapter make_adapter(ChatMessageListScreen* screen) +{ + Adapter adapter{}; + adapter.ctx = screen; + adapter.is_alive = screen_alive; + adapter.get_key_target = get_key_target; + adapter.get_top_back_button = get_top_back_button; + adapter.get_filter_count = get_filter_count; + adapter.get_filter_button = get_filter_button; + adapter.get_preferred_filter_index = get_preferred_filter_index; + adapter.get_list_count = get_list_count; + adapter.get_list_button = get_list_button; + adapter.get_preferred_list_index = get_preferred_list_index; + adapter.get_list_back_button = get_list_back_button; + adapter.filter_top_back_placement = BackPlacement::Leading; + return adapter; } } // namespace void init(ChatMessageListScreen* screen, Binding* binding) { - if (!binding) - { - return; - } - if (binding->bound) - { - cleanup(binding); - } - - binding->screen = screen; - binding->group = lv_group_create(); - binding->prev_group = lv_group_get_default(); - set_default_group(nullptr); - binding->col = FocusColumn::Filter; - rebind_by_column(binding); - set_default_group(binding->group); - - if (binding->screen && binding->screen->getObj()) - { - lv_obj_add_event_cb(binding->screen->getObj(), root_key_event_cb, LV_EVENT_KEY, binding); - } - binding->bound = true; - - CHAT_INPUT_LOG("[ChatMessageListInput] init\n"); + if (!binding) return; + ::ui::components::two_pane_nav::init(binding, make_adapter(screen)); } void cleanup(Binding* binding) { - if (!binding || !binding->bound) - { - return; - } - binding->screen = nullptr; - if (binding->group) - { - set_default_group(nullptr); - lv_group_del(binding->group); - binding->group = nullptr; - } - if (binding->prev_group) - { - set_default_group(binding->prev_group); - } - binding->prev_group = nullptr; - binding->bound = false; - CHAT_INPUT_LOG("[ChatMessageListInput] cleanup\n"); + ::ui::components::two_pane_nav::cleanup(binding); } void on_ui_refreshed(Binding* binding) { - if (!binding || !binding->group || !screen_alive(binding)) return; - rebind_by_column(binding); + ::ui::components::two_pane_nav::on_ui_refreshed(binding); } void focus_filter(Binding* binding) { - if (!binding || !binding->group || !screen_alive(binding)) return; - binding->col = FocusColumn::Filter; - rebind_by_column(binding); + ::ui::components::two_pane_nav::focus_filter(binding); } void focus_list(Binding* binding) { - if (!binding || !binding->group || !screen_alive(binding)) return; - binding->col = FocusColumn::List; - rebind_by_column(binding); + ::ui::components::two_pane_nav::focus_list(binding); } -} // namespace chat::ui::message_list::input +} // namespace input +} // namespace message_list +} // namespace ui +} // namespace chat #endif diff --git a/src/ui/screens/chat/chat_message_list_input.h b/src/ui/screens/chat/chat_message_list_input.h index c1fd62a1..c220f373 100644 --- a/src/ui/screens/chat/chat_message_list_input.h +++ b/src/ui/screens/chat/chat_message_list_input.h @@ -1,55 +1,34 @@ /** * @file chat_message_list_input.h - * @brief Input handling for ChatMessageListScreen (explicit layer) - * - * This module owns the navigation/focus policy for this screen. - * For now it is intentionally minimal (no behavior change), - * but it provides the official extension point for rotary/keys. + * @brief Input handling for ChatMessageListScreen */ #pragma once -#include "lvgl.h" +#include "../../components/two_pane_nav.h" -namespace chat::ui +namespace chat +{ +namespace ui { class ChatMessageListScreen; -namespace message_list::input +namespace message_list +{ +namespace input { -enum class FocusColumn -{ - Filter = 0, - List = 1 -}; +using FocusColumn = ::ui::components::two_pane_nav::FocusColumn; +using Binding = ::ui::components::two_pane_nav::Binding; -struct Binding -{ - ChatMessageListScreen* screen = nullptr; - lv_group_t* group = nullptr; - lv_group_t* prev_group = nullptr; - FocusColumn col = FocusColumn::Filter; - bool bound = false; -}; - -/** - * @brief Initialize input handling for the screen - */ void init(ChatMessageListScreen* screen, Binding* binding); - -/** - * @brief Clean up input handling - */ void cleanup(Binding* binding); - -/** - * @brief Rebind focus after UI rebuild - */ void on_ui_refreshed(Binding* binding); void focus_filter(Binding* binding); void focus_list(Binding* binding); -} // namespace message_list::input -} // namespace chat::ui +} // namespace input +} // namespace message_list +} // namespace ui +} // namespace chat diff --git a/src/ui/screens/chat/chat_message_list_layout.cpp b/src/ui/screens/chat/chat_message_list_layout.cpp index c2b328e8..97b894e5 100644 --- a/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/src/ui/screens/chat/chat_message_list_layout.cpp @@ -2,115 +2,60 @@ /** * @file chat_message_list_layout.cpp * @brief Layout (structure only) for ChatMessageListScreen - * - * UI Wireframe / Layout Tree (ChatMessageListScreen) - * -------------------------------------------------------------------- - * - * Root Container (COLUMN, full screen) - * - * ┌───────────────────────────────────────────────────────────────────� * � TopBar (fixed height, external widget) � * � ┌─────────────────────────────────────────────────────────────� � * � � < Back MESSAGES --:-- --% � � * � └─────────────────────────────────────────────────────────────� � * � � * � Panel (COLUMN, flex-grow = 1, light gray background) � * � ┌─────────────────────────────────────────────────────────────� � * � � Message Item (Button, height=28) � � * � � ┌────────────────────────────────────────────────────────�� � * � � �Name (x=10) Preview (x=120, w=130, DOT) 12:34 �� � * � � � Unread (x=-42) �� � * � � └────────────────────────────────────────────────────────�� � * � � � � * � � Message Item × N � � * � � ... � � * � � � � * � � (Empty State) � � * � � "No messages" (centered) � � * � └─────────────────────────────────────────────────────────────� � * └───────────────────────────────────────────────────────────────────� * - * Tree view: - * ------------------------------------------------------------ - * Root (COLUMN, full screen) - * ├─ TopBar (external widget) - * └─ Panel (COLUMN, grow=1) - * ├─ MessageItemBtn (repeated) - * � ├─ NameLabel (left) - * � ├─ PreviewLabel(left, clipped) - * � ├─ UnreadLabel (right, near time) - * � └─ TimeLabel (right) - * └─ (or) PlaceholderLabel ("No messages", centered) - * - * Key layout constraints: - * - Panel: pad_all=3, pad_left/right=5, pad_row=2, non-scrollable - * - ItemBtn: height=28, full width - * - NameLabel: ALIGN_LEFT_MID x=+10 - * - PreviewLabel:ALIGN_LEFT_MID x=+120, width=130, LONG_DOT - * - TimeLabel: ALIGN_RIGHT_MID x=-10 - * - UnreadLabel: ALIGN_RIGHT_MID x=-42 - - - * Notes: - * - Structure only: create objects, set flex/size/align/flags. - * - Styles are applied via chat_message_list_styles.*. */ #include "chat_message_list_layout.h" +#include "../../components/two_pane_layout.h" #include namespace chat::ui::layout { - -static void make_non_scrollable(lv_obj_t* obj) -{ - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); -} - namespace { + constexpr int kFilterPanelWidth = 80; constexpr int kButtonHeight = 28; constexpr int kPanelGap = 0; -constexpr int kScreenEdgePadding = 0; -} // namespace lv_obj_t* create_root(lv_obj_t* parent) { - lv_obj_t* root = lv_obj_create(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(root, 3, 0); - lv_obj_set_style_pad_all(root, 0, 0); // layout padding only - make_non_scrollable(root); - return root; + return ::ui::components::two_pane_layout::create_root(parent); } -static lv_obj_t* create_content(lv_obj_t* parent) +lv_obj_t* create_content(lv_obj_t* parent) { - lv_obj_t* content = lv_obj_create(parent); - lv_obj_set_size(content, LV_PCT(100), 0); - lv_obj_set_flex_grow(content, 1); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_left(content, kScreenEdgePadding, 0); - lv_obj_set_style_pad_right(content, kScreenEdgePadding, 0); - lv_obj_set_style_pad_top(content, 0, 0); - lv_obj_set_style_pad_bottom(content, 0, 0); - make_non_scrollable(content); - return content; + return ::ui::components::two_pane_layout::create_content_row(parent); } -static lv_obj_t* create_filter_panel(lv_obj_t* parent, - lv_obj_t** direct_btn, - lv_obj_t** broadcast_btn, - lv_obj_t** team_btn) +lv_obj_t* create_filter_panel(lv_obj_t* parent, + lv_obj_t** direct_btn, + lv_obj_t** broadcast_btn, + lv_obj_t** team_btn) { - lv_obj_t* panel = lv_obj_create(parent); - lv_obj_set_width(panel, kFilterPanelWidth); - lv_obj_set_height(panel, LV_PCT(100)); - lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(panel, 3, LV_PART_MAIN); - lv_obj_set_style_margin_right(panel, kPanelGap, LV_PART_MAIN); - make_non_scrollable(panel); + ::ui::components::two_pane_layout::SidePanelSpec panel_spec; + panel_spec.width = kFilterPanelWidth; + panel_spec.pad_row = 3; + panel_spec.margin_left = 0; + panel_spec.margin_right = kPanelGap; + lv_obj_t* panel = ::ui::components::two_pane_layout::create_side_panel(parent, panel_spec); lv_obj_t* direct = lv_btn_create(panel); lv_obj_set_size(direct, LV_PCT(100), kButtonHeight); - make_non_scrollable(direct); + ::ui::components::two_pane_layout::make_non_scrollable(direct); lv_obj_t* direct_label = lv_label_create(direct); lv_label_set_text(direct_label, "Direct"); lv_obj_center(direct_label); lv_obj_t* broadcast = lv_btn_create(panel); lv_obj_set_size(broadcast, LV_PCT(100), kButtonHeight); - make_non_scrollable(broadcast); + ::ui::components::two_pane_layout::make_non_scrollable(broadcast); lv_obj_t* broadcast_label = lv_label_create(broadcast); lv_label_set_text(broadcast_label, "Broadcast"); lv_obj_center(broadcast_label); lv_obj_t* team = lv_btn_create(panel); lv_obj_set_size(team, LV_PCT(100), kButtonHeight); - make_non_scrollable(team); + ::ui::components::two_pane_layout::make_non_scrollable(team); lv_obj_t* team_label = lv_label_create(team); lv_label_set_text(team_label, "Team"); lv_obj_center(team_label); @@ -122,25 +67,19 @@ static lv_obj_t* create_filter_panel(lv_obj_t* parent, return panel; } -static lv_obj_t* create_list_panel(lv_obj_t* parent) +lv_obj_t* create_list_panel(lv_obj_t* parent) { - lv_obj_t* panel = lv_obj_create(parent); - lv_obj_set_height(panel, LV_PCT(100)); - lv_obj_set_width(panel, 0); - lv_obj_set_flex_grow(panel, 1); - lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - - // layout spacing only - lv_obj_set_style_pad_row(panel, 3, 0); - lv_obj_set_style_pad_left(panel, 5, 0); - lv_obj_set_style_pad_right(panel, 5, 0); - lv_obj_set_style_pad_all(panel, 3, 0); - - make_non_scrollable(panel); - return panel; + ::ui::components::two_pane_layout::MainPanelSpec panel_spec; + panel_spec.pad_all = 3; + panel_spec.pad_row = 3; + panel_spec.pad_left = 5; + panel_spec.pad_right = 5; + panel_spec.scrollbar_mode = LV_SCROLLBAR_MODE_OFF; + return ::ui::components::two_pane_layout::create_main_panel(parent, panel_spec); } +} // namespace + MessageListLayout create_layout(lv_obj_t* parent) { MessageListLayout w{}; @@ -156,7 +95,7 @@ MessageItemWidgets create_message_item(lv_obj_t* parent) MessageItemWidgets w{}; w.btn = lv_btn_create(parent); lv_obj_set_size(w.btn, LV_PCT(100), 28); - make_non_scrollable(w.btn); + ::ui::components::two_pane_layout::make_non_scrollable(w.btn); w.name_label = lv_label_create(w.btn); lv_obj_align(w.name_label, LV_ALIGN_LEFT_MID, 10, 0); diff --git a/src/ui/screens/chat/chat_message_list_layout.h b/src/ui/screens/chat/chat_message_list_layout.h index 503a9ba8..582c9d4b 100644 --- a/src/ui/screens/chat/chat_message_list_layout.h +++ b/src/ui/screens/chat/chat_message_list_layout.h @@ -16,9 +16,6 @@ struct MessageListLayout lv_obj_t* team_btn; }; -// root/page -lv_obj_t* create_root(lv_obj_t* parent); - // split content (filter + list) MessageListLayout create_layout(lv_obj_t* parent); diff --git a/src/ui/screens/chat/chat_message_list_styles.cpp b/src/ui/screens/chat/chat_message_list_styles.cpp index da8ed616..be8f274c 100644 --- a/src/ui/screens/chat/chat_message_list_styles.cpp +++ b/src/ui/screens/chat/chat_message_list_styles.cpp @@ -1,111 +1,35 @@ #if !defined(ARDUINO_T_WATCH_S3) #include "chat_message_list_styles.h" #include "../../assets/fonts/fonts.h" +#include "../../components/two_pane_styles.h" namespace chat::ui::message_list::styles { +namespace +{ -static constexpr uint32_t kPrimary = 0xEBA341; -static constexpr uint32_t kPrimaryLight = 0xEBA341; -static constexpr uint32_t kCardBg = 0xFFF7E9; -static constexpr uint32_t kSoftBg = 0xFFF3DF; -static constexpr uint32_t kTextMain = 0x3A2A1A; -static constexpr uint32_t kTextMuted = 0x6A5646; +bool s_inited = false; +lv_style_t s_root; +lv_style_t s_label_font; -static bool inited = false; - -static lv_style_t s_root; -static lv_style_t s_panel; -static lv_style_t s_filter_panel; - -static lv_style_t s_item_btn; -static lv_style_t s_item_btn_focused; -static lv_style_t s_filter_btn; -static lv_style_t s_filter_btn_checked; - -static lv_style_t s_label_name; -static lv_style_t s_label_preview; -static lv_style_t s_label_time; -static lv_style_t s_label_unread; -static lv_style_t s_label_placeholder; +} // namespace void init_once() { - if (inited) return; - inited = true; + ::ui::components::two_pane_styles::init_once(); + if (s_inited) return; + s_inited = true; - // Root container lv_style_init(&s_root); - lv_style_set_bg_color(&s_root, lv_color_hex(0xFFF3DF)); lv_style_set_bg_opa(&s_root, LV_OPA_COVER); + lv_style_set_bg_color(&s_root, + lv_color_hex(::ui::components::two_pane_styles::kSidePanelBg)); lv_style_set_border_width(&s_root, 0); lv_style_set_pad_all(&s_root, 0); lv_style_set_radius(&s_root, 0); - // Panel background - lv_style_init(&s_panel); - lv_style_set_bg_color(&s_panel, lv_color_hex(kSoftBg)); - lv_style_set_bg_opa(&s_panel, LV_OPA_COVER); - lv_style_set_border_width(&s_panel, 0); - lv_style_set_pad_all(&s_panel, 3); - lv_style_set_radius(&s_panel, 0); - - // Filter panel background - lv_style_init(&s_filter_panel); - lv_style_set_bg_color(&s_filter_panel, lv_color_hex(kSoftBg)); - lv_style_set_bg_opa(&s_filter_panel, LV_OPA_COVER); - lv_style_set_border_width(&s_filter_panel, 0); - lv_style_set_pad_all(&s_filter_panel, 2); - lv_style_set_radius(&s_filter_panel, 0); - - // Item button - lv_style_init(&s_item_btn); - lv_style_set_bg_color(&s_item_btn, lv_color_hex(kCardBg)); - lv_style_set_bg_opa(&s_item_btn, LV_OPA_COVER); - lv_style_set_border_width(&s_item_btn, 1); - lv_style_set_border_color(&s_item_btn, lv_color_hex(kPrimary)); - lv_style_set_radius(&s_item_btn, 6); - lv_style_set_text_color(&s_item_btn, lv_color_hex(kTextMain)); - - lv_style_init(&s_item_btn_focused); - lv_style_set_bg_opa(&s_item_btn_focused, LV_OPA_COVER); - lv_style_set_bg_color(&s_item_btn_focused, lv_color_hex(kPrimaryLight)); - lv_style_set_outline_width(&s_item_btn_focused, 2); - lv_style_set_outline_color(&s_item_btn_focused, lv_color_hex(kPrimary)); - - // Filter button - lv_style_init(&s_filter_btn); - lv_style_set_bg_color(&s_filter_btn, lv_color_hex(kCardBg)); - lv_style_set_bg_opa(&s_filter_btn, LV_OPA_COVER); - lv_style_set_border_width(&s_filter_btn, 1); - lv_style_set_border_color(&s_filter_btn, lv_color_hex(kPrimary)); - lv_style_set_radius(&s_filter_btn, 6); - lv_style_set_text_color(&s_filter_btn, lv_color_hex(kTextMain)); - - lv_style_init(&s_filter_btn_checked); - lv_style_set_bg_opa(&s_filter_btn_checked, LV_OPA_COVER); - lv_style_set_bg_color(&s_filter_btn_checked, lv_color_hex(kPrimary)); - - // Labels - lv_style_init(&s_label_name); - lv_style_set_text_color(&s_label_name, lv_color_hex(kTextMain)); - lv_style_set_text_font(&s_label_name, &lv_font_noto_cjk_16_2bpp); - - lv_style_init(&s_label_preview); - lv_style_set_text_color(&s_label_preview, lv_color_hex(kTextMuted)); - lv_style_set_text_font(&s_label_preview, &lv_font_noto_cjk_16_2bpp); - - lv_style_init(&s_label_time); - lv_style_set_text_color(&s_label_time, lv_color_hex(kTextMuted)); - lv_style_set_text_font(&s_label_time, &lv_font_noto_cjk_16_2bpp); - - lv_style_init(&s_label_unread); - lv_style_set_text_color(&s_label_unread, lv_color_hex(kPrimary)); - lv_style_set_text_font(&s_label_unread, &lv_font_noto_cjk_16_2bpp); - - lv_style_init(&s_label_placeholder); - lv_style_set_text_color(&s_label_placeholder, lv_color_hex(kTextMuted)); - lv_style_set_text_font(&s_label_placeholder, &lv_font_noto_cjk_16_2bpp); + lv_style_init(&s_label_font); + lv_style_set_text_font(&s_label_font, &lv_font_noto_cjk_16_2bpp); } void apply_root_container(lv_obj_t* obj) @@ -116,54 +40,57 @@ void apply_root_container(lv_obj_t* obj) void apply_panel(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_panel, 0); + ::ui::components::two_pane_styles::apply_panel_main(obj); } void apply_filter_panel(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_filter_panel, 0); + ::ui::components::two_pane_styles::apply_panel_side(obj); } void apply_item_btn(lv_obj_t* btn) { - init_once(); - lv_obj_add_style(btn, &s_item_btn, LV_PART_MAIN); - lv_obj_add_style(btn, &s_item_btn_focused, LV_STATE_FOCUSED); + ::ui::components::two_pane_styles::apply_list_item(btn); } void apply_filter_btn(lv_obj_t* btn) { - init_once(); - lv_obj_add_style(btn, &s_filter_btn, LV_PART_MAIN); - lv_obj_add_style(btn, &s_filter_btn_checked, LV_STATE_CHECKED); + ::ui::components::two_pane_styles::apply_btn_filter(btn); } void apply_label_name(lv_obj_t* label) { init_once(); - lv_obj_add_style(label, &s_label_name, 0); + ::ui::components::two_pane_styles::apply_label_primary(label); + lv_obj_add_style(label, &s_label_font, 0); } + void apply_label_preview(lv_obj_t* label) { init_once(); - lv_obj_add_style(label, &s_label_preview, 0); + ::ui::components::two_pane_styles::apply_label_muted(label); + lv_obj_add_style(label, &s_label_font, 0); } + void apply_label_time(lv_obj_t* label) { init_once(); - lv_obj_add_style(label, &s_label_time, 0); + ::ui::components::two_pane_styles::apply_label_muted(label); + lv_obj_add_style(label, &s_label_font, 0); } + void apply_label_unread(lv_obj_t* label) { init_once(); - lv_obj_add_style(label, &s_label_unread, 0); + ::ui::components::two_pane_styles::apply_label_accent(label); + lv_obj_add_style(label, &s_label_font, 0); } + void apply_label_placeholder(lv_obj_t* label) { init_once(); - lv_obj_add_style(label, &s_label_placeholder, 0); + ::ui::components::two_pane_styles::apply_label_muted(label); + lv_obj_add_style(label, &s_label_font, 0); } } // namespace chat::ui::message_list::styles diff --git a/src/ui/screens/contacts/contacts_page_components.cpp b/src/ui/screens/contacts/contacts_page_components.cpp index fccbf906..51f4cca0 100644 --- a/src/ui/screens/contacts/contacts_page_components.cpp +++ b/src/ui/screens/contacts/contacts_page_components.cpp @@ -1,6 +1,6 @@ /** * @file contacts_page_components.cpp - * @brief Contacts page UI components implementation (refactor: layout/styles split, behavior preserved) + * @brief Contacts page behavior implementation on top of shared layout/styles */ #include // Must be first for library compilation @@ -47,6 +47,7 @@ static constexpr int kItemsPerPage = 4; static constexpr int kButtonHeight = 28; static constexpr int kBottomBtnMinWidth = 50; static constexpr int kBottomBtnPadH = 8; +static constexpr intptr_t kBackListItemUserData = -2; // UI color tokens (must align with docs/skyplot.md) static constexpr uint32_t kColorAmber = 0xEBA341; @@ -71,14 +72,13 @@ static uint32_t s_last_sent_ts = 0; static uint32_t s_team_msg_id = 1; // --- Forward declarations (same behavior as original) --- -static void contacts_btn_event_handler(lv_event_t* e); -static void nearby_btn_event_handler(lv_event_t* e); static std::string format_time_status(uint32_t last_seen); static std::string format_snr(float snr); static void on_filter_focused(lv_event_t* e); static void on_filter_clicked(lv_event_t* e); static void on_list_item_clicked(lv_event_t* e); +static void on_list_item_focused(lv_event_t* e); static void on_prev_clicked(lv_event_t* e); static void on_next_clicked(lv_event_t* e); static void on_back_clicked(lv_event_t* e); @@ -578,7 +578,7 @@ void create_filter_panel(lv_obj_t* parent) // - Rotate in Filter column triggers FOCUSED -> switch mode + refresh // - Press in Filter column: // * on TopBar back -> exit (handled by topbar) - // * on Contacts/Nearby -> move focus to List column + // * on a mode button -> move focus to the List column if (g_contacts_state.contacts_btn) { lv_obj_add_event_cb(g_contacts_state.contacts_btn, on_filter_focused, LV_EVENT_FOCUSED, nullptr); @@ -610,43 +610,8 @@ void create_filter_panel(lv_obj_t* parent) refresh_filter_checked_state(); } -void create_list_panel(lv_obj_t* parent) -{ - contacts::ui::layout::create_list_panel(parent); -} - -void create_action_panel(lv_obj_t* parent) -{ - contacts::ui::layout::create_action_panel(parent); - // Buttons will be created/updated in refresh_ui() based on selected item (unchanged) -} - // ---------------- Filter handlers (unchanged behavior) ---------------- -static void contacts_btn_event_handler(lv_event_t* e) -{ - lv_event_code_t code = lv_event_get_code(e); - if (code == LV_EVENT_CLICKED) - { - CONTACTS_LOG("[Contacts] Contacts button clicked\n"); - g_contacts_state.current_mode = ContactsMode::Contacts; - g_contacts_state.current_page = 0; // Reset to first page - refresh_ui(); - } -} - -static void nearby_btn_event_handler(lv_event_t* e) -{ - lv_event_code_t code = lv_event_get_code(e); - if (code == LV_EVENT_CLICKED) - { - CONTACTS_LOG("[Contacts] Nearby button clicked\n"); - g_contacts_state.current_mode = ContactsMode::Nearby; - g_contacts_state.current_page = 0; // Reset to first page - refresh_ui(); - } -} - static void on_filter_focused(lv_event_t* e) { lv_obj_t* tgt = (lv_obj_t*)lv_event_get_target(e); @@ -698,6 +663,12 @@ static void on_list_item_clicked(lv_event_t* e) { lv_obj_t* item = (lv_obj_t*)lv_event_get_target(e); g_contacts_state.selected_index = (int)(intptr_t)lv_obj_get_user_data(item); + if (g_contacts_state.selected_index == static_cast(kBackListItemUserData)) + { + g_contacts_state.selected_index = -1; + contacts_focus_to_filter(); + return; + } if (g_contacts_state.current_mode == ContactsMode::Discover) { execute_discovery_command(static_cast(g_contacts_state.selected_index)); @@ -715,6 +686,20 @@ static void on_list_item_clicked(lv_event_t* e) open_action_menu_modal(); } +static void on_list_item_focused(lv_event_t* e) +{ + if (!e) + { + return; + } + lv_obj_t* item = (lv_obj_t*)lv_event_get_target(e); + if (!item || !lv_obj_is_valid(item)) + { + return; + } + lv_obj_scroll_to_view(item, LV_ANIM_OFF); +} + static void on_prev_clicked(lv_event_t* /*e*/) { if (lv_obj_has_state(g_contacts_state.prev_btn, LV_STATE_DISABLED)) return; @@ -2321,13 +2306,24 @@ void refresh_ui() g_contacts_state.total_items = current_list->size(); + const bool use_scroll_list = + (g_contacts_state.current_mode == ContactsMode::Contacts) || + (g_contacts_state.current_mode == ContactsMode::Nearby) || + (g_contacts_state.current_mode == ContactsMode::Broadcast) || + (g_contacts_state.current_mode == ContactsMode::Discover); + const bool append_back_item = use_scroll_list; + if (append_back_item) + { + g_contacts_state.total_items += 1; + } + if (g_contacts_state.selected_index >= static_cast(g_contacts_state.total_items)) { g_contacts_state.selected_index = -1; } // Pagination calc (unchanged) - int total_pages = (static_cast(g_contacts_state.total_items) + kItemsPerPage - 1) / kItemsPerPage; + int total_pages = use_scroll_list ? 1 : (static_cast(g_contacts_state.total_items) + kItemsPerPage - 1) / kItemsPerPage; if (total_pages == 0) total_pages = 1; if (g_contacts_state.current_page >= total_pages) @@ -2339,11 +2335,11 @@ void refresh_ui() g_contacts_state.current_page = 0; } - int start_idx = g_contacts_state.current_page * kItemsPerPage; - int end_idx = start_idx + kItemsPerPage; - if (end_idx > static_cast(g_contacts_state.total_items)) + int start_idx = use_scroll_list ? 0 : (g_contacts_state.current_page * kItemsPerPage); + int end_idx = use_scroll_list ? static_cast(current_list->size()) : (start_idx + kItemsPerPage); + if (end_idx > static_cast(current_list->size())) { - end_idx = static_cast(g_contacts_state.total_items); + end_idx = static_cast(current_list->size()); } // Create list items for current page (structure in layout; status string computed here) @@ -2412,6 +2408,21 @@ void refresh_ui() // Clicking a list row opens the action menu. lv_obj_add_event_cb(item, on_list_item_clicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(item, on_list_item_focused, LV_EVENT_FOCUSED, nullptr); + } + + if (append_back_item) + { + chat::contacts::NodeInfo back_node{}; + back_node.display_name = "Back"; + lv_obj_t* back_item = contacts::ui::layout::create_list_item( + g_contacts_state.sub_container, + back_node, + g_contacts_state.current_mode, + "Return"); + lv_obj_set_user_data(back_item, reinterpret_cast(kBackListItemUserData)); + lv_obj_add_event_cb(back_item, on_list_item_clicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(back_item, on_list_item_focused, LV_EVENT_FOCUSED, nullptr); } // Create bottom buttons (create once; width follows label text) @@ -2442,8 +2453,20 @@ void refresh_ui() on_back_clicked); } + if (g_contacts_state.bottom_container) + { + if (use_scroll_list) + { + lv_obj_add_flag(g_contacts_state.bottom_container, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_clear_flag(g_contacts_state.bottom_container, LV_OBJ_FLAG_HIDDEN); + } + } + // Enable/disable buttons based on pagination (unchanged from original) - if (total_pages > 1) + if (!use_scroll_list && total_pages > 1) { lv_obj_clear_state(g_contacts_state.prev_btn, LV_STATE_DISABLED); lv_obj_clear_state(g_contacts_state.next_btn, LV_STATE_DISABLED); @@ -2462,13 +2485,27 @@ void refresh_ui() { lv_obj_scroll_to_y(g_contacts_state.list_panel, 0, LV_ANIM_OFF); lv_obj_invalidate(g_contacts_state.list_panel); - lv_obj_add_flag(g_contacts_state.list_panel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(g_contacts_state.list_panel, LV_SCROLLBAR_MODE_AUTO); + if (use_scroll_list) + { + lv_obj_clear_flag(g_contacts_state.list_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(g_contacts_state.list_panel, LV_SCROLLBAR_MODE_OFF); + } + else + { + lv_obj_add_flag(g_contacts_state.list_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(g_contacts_state.list_panel, LV_SCROLLBAR_MODE_AUTO); + } } if (g_contacts_state.sub_container) { lv_obj_add_flag(g_contacts_state.sub_container, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(g_contacts_state.sub_container, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_scroll_dir(g_contacts_state.sub_container, LV_DIR_VER); + lv_obj_set_scrollbar_mode(g_contacts_state.sub_container, + use_scroll_list ? LV_SCROLLBAR_MODE_AUTO : LV_SCROLLBAR_MODE_OFF); + if (use_scroll_list) + { + lv_obj_scroll_to_y(g_contacts_state.sub_container, 0, LV_ANIM_OFF); + } } s_refreshing_ui = false; contacts_input_on_ui_refreshed(); diff --git a/src/ui/screens/contacts/contacts_page_components.h b/src/ui/screens/contacts/contacts_page_components.h index c6402009..0701769b 100644 --- a/src/ui/screens/contacts/contacts_page_components.h +++ b/src/ui/screens/contacts/contacts_page_components.h @@ -1,10 +1,6 @@ /** * @file contacts_page_components.h - * @brief Contacts page UI components (public interface) - * - * This header exposes the public UI construction and refresh APIs - * for the Contacts page. It does not include layout, style, or input - * details, which are handled internally. + * @brief Contacts page behavior and refresh API */ #pragma once @@ -12,49 +8,11 @@ #include "lvgl.h" /** - * @brief Create filter panel (first column: Contacts / Nearby) - * - * @param parent Parent LVGL object (expected to be a horizontal flex container) + * Create the filter column UI and bind its focus/click events. + * The list column structure is created directly via contacts::ui::layout. */ void create_filter_panel(lv_obj_t* parent); -/** - * @brief Create list panel (second column: contacts list) - * - * @param parent Parent LVGL object - */ -void create_list_panel(lv_obj_t* parent); - -/** - * @brief Create action panel (third column: context actions) - * - * @param parent Parent LVGL object - */ -void create_action_panel(lv_obj_t* parent); - -/** - * @brief Refresh contacts data from ContactService - * - * Note: The actual implementation is located in ui_contacts.cpp - * to avoid Arduino framework dependency issues. - */ void refresh_contacts_data(); - -/** - * @brief Refresh the Contacts page UI - * - * This will: - * - Rebuild the visible list items for the current page - * - Update pagination buttons (enable/disable) - * - Update filter button highlight (Contacts / Nearby) - * - * No input behavior is changed by calling this function. - */ void refresh_ui(); - -/** - * @brief Clean up modal windows related to the Contacts page - * - * Safely deletes and clears modal object pointers if they exist. - */ void cleanup_modals(); diff --git a/src/ui/screens/contacts/contacts_page_input.cpp b/src/ui/screens/contacts/contacts_page_input.cpp index 4612eb51..19c725af 100644 --- a/src/ui/screens/contacts/contacts_page_input.cpp +++ b/src/ui/screens/contacts/contacts_page_input.cpp @@ -4,466 +4,170 @@ */ #include "contacts_page_input.h" -#include "../../ui_common.h" + +#include "../../components/two_pane_nav.h" #include "contacts_state.h" -#include // Must be first for library compilation -#include // Required for contacts_state.h -> contact_service.h dependency chain - -#define CONTACTS_DEBUG 0 -#if CONTACTS_DEBUG -#define CONTACTS_LOG(...) Serial.printf(__VA_ARGS__) -#else -#define CONTACTS_LOG(...) -#endif - -using namespace contacts::ui; namespace { -enum class FocusColumn -{ - Filter = 0, - List = 1, -}; +using Adapter = ::ui::components::two_pane_nav::Adapter; +using BackPlacement = ::ui::components::two_pane_nav::BackPlacement; -static lv_group_t* s_group = nullptr; -static lv_group_t* s_prev_group = nullptr; -static FocusColumn s_col = FocusColumn::Filter; -static lv_indev_t* s_encoder = nullptr; +static ::ui::components::two_pane_nav::Binding s_binding{}; -static lv_indev_t* find_encoder_indev() +static int mode_to_index(contacts::ui::ContactsMode mode) { - lv_indev_t* indev = nullptr; - while ((indev = lv_indev_get_next(indev)) != nullptr) + switch (mode) { - if (lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER) - { - return indev; - } + case contacts::ui::ContactsMode::Contacts: + return 0; + case contacts::ui::ContactsMode::Nearby: + return 1; + case contacts::ui::ContactsMode::Broadcast: + return 2; + case contacts::ui::ContactsMode::Team: + return 3; + case contacts::ui::ContactsMode::Discover: + return 4; } - return nullptr; + return 0; } -static bool is_encoder_active() +static lv_obj_t* get_key_target(void* /*ctx*/) { - lv_indev_t* indev = lv_indev_get_act(); - return indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; + return contacts::ui::g_contacts_state.root + ? contacts::ui::g_contacts_state.root + : contacts::ui::g_contacts_state.list_panel; } -static void group_clear_all(lv_group_t* g) +static lv_obj_t* get_top_back_button(void* /*ctx*/) { - if (!g) return; - lv_group_remove_all_objs(g); + return contacts::ui::g_contacts_state.top_bar.back_btn; } -static void clear_action_focus_states() +static size_t get_filter_count(void* /*ctx*/) { - // Action panel removed. Keep function as no-op to preserve call sites. + return 5; } -static bool is_valid(lv_obj_t* obj) +static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) { - return obj && lv_obj_is_valid(obj); -} - -static bool is_visible(lv_obj_t* obj) -{ - return is_valid(obj) && !lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN); -} - -static void root_key_event_cb(lv_event_t* e); - -static void attach_key_handler(lv_obj_t* obj) -{ - if (!obj || !lv_obj_is_valid(obj)) return; - lv_obj_remove_event_cb(obj, root_key_event_cb); - lv_obj_add_event_cb(obj, root_key_event_cb, LV_EVENT_KEY, nullptr); -} - -static void focus_first_valid(lv_obj_t* obj) -{ - if (!s_group || !is_valid(obj)) return; - lv_group_focus_obj(obj); -} - -static void bind_filter_column(bool keep_mode_focus) -{ - if (!s_group) return; - group_clear_all(s_group); - clear_action_focus_states(); - - // 1) TopBar Back — first escape route in the focus order; must always be reachable. - if (is_valid(g_contacts_state.top_bar.back_btn)) + using namespace contacts::ui; + switch (index) { - lv_group_add_obj(s_group, g_contacts_state.top_bar.back_btn); - attach_key_handler(g_contacts_state.top_bar.back_btn); - } - - // ② Filter buttons - if (is_visible(g_contacts_state.contacts_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.contacts_btn); - attach_key_handler(g_contacts_state.contacts_btn); - } - if (is_visible(g_contacts_state.nearby_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.nearby_btn); - attach_key_handler(g_contacts_state.nearby_btn); - } - if (is_visible(g_contacts_state.broadcast_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.broadcast_btn); - attach_key_handler(g_contacts_state.broadcast_btn); - } - if (is_visible(g_contacts_state.team_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.team_btn); - attach_key_handler(g_contacts_state.team_btn); - } - if (is_visible(g_contacts_state.discover_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.discover_btn); - attach_key_handler(g_contacts_state.discover_btn); - } - - // Focus preference: - // - keep_mode_focus: focus current mode button (better for rotate-to-switch-mode UX) - // - else: focus current mode button; fallback to back - if (keep_mode_focus) - { - if (g_contacts_state.current_mode == ContactsMode::Contacts && g_contacts_state.contacts_btn) - { - focus_first_valid(g_contacts_state.contacts_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Nearby && g_contacts_state.nearby_btn) - { - focus_first_valid(g_contacts_state.nearby_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Broadcast && is_visible(g_contacts_state.broadcast_btn)) - { - focus_first_valid(g_contacts_state.broadcast_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Team && is_visible(g_contacts_state.team_btn)) - { - focus_first_valid(g_contacts_state.team_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Discover && is_visible(g_contacts_state.discover_btn)) - { - focus_first_valid(g_contacts_state.discover_btn); - } - else if (g_contacts_state.top_bar.back_btn) - { - focus_first_valid(g_contacts_state.top_bar.back_btn); - } - else if (is_visible(g_contacts_state.contacts_btn)) - { - focus_first_valid(g_contacts_state.contacts_btn); - } - else if (is_visible(g_contacts_state.nearby_btn)) - { - focus_first_valid(g_contacts_state.nearby_btn); - } - else if (is_visible(g_contacts_state.broadcast_btn)) - { - focus_first_valid(g_contacts_state.broadcast_btn); - } - else if (is_visible(g_contacts_state.team_btn)) - { - focus_first_valid(g_contacts_state.team_btn); - } - else if (is_visible(g_contacts_state.discover_btn)) - { - focus_first_valid(g_contacts_state.discover_btn); - } - } - else - { - // first entry: prefer current mode button - if (g_contacts_state.current_mode == ContactsMode::Contacts && is_visible(g_contacts_state.contacts_btn)) - { - focus_first_valid(g_contacts_state.contacts_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Nearby && is_visible(g_contacts_state.nearby_btn)) - { - focus_first_valid(g_contacts_state.nearby_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Broadcast && is_visible(g_contacts_state.broadcast_btn)) - { - focus_first_valid(g_contacts_state.broadcast_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Team && is_visible(g_contacts_state.team_btn)) - { - focus_first_valid(g_contacts_state.team_btn); - } - else if (g_contacts_state.current_mode == ContactsMode::Discover && is_visible(g_contacts_state.discover_btn)) - { - focus_first_valid(g_contacts_state.discover_btn); - } - else if (is_visible(g_contacts_state.contacts_btn)) - { - focus_first_valid(g_contacts_state.contacts_btn); - } - else if (is_visible(g_contacts_state.nearby_btn)) - { - focus_first_valid(g_contacts_state.nearby_btn); - } - else if (is_visible(g_contacts_state.broadcast_btn)) - { - focus_first_valid(g_contacts_state.broadcast_btn); - } - else if (is_visible(g_contacts_state.team_btn)) - { - focus_first_valid(g_contacts_state.team_btn); - } - else if (is_visible(g_contacts_state.discover_btn)) - { - focus_first_valid(g_contacts_state.discover_btn); - } - else if (g_contacts_state.top_bar.back_btn) - { - focus_first_valid(g_contacts_state.top_bar.back_btn); - } + case 0: + return g_contacts_state.contacts_btn; + case 1: + return g_contacts_state.nearby_btn; + case 2: + return g_contacts_state.broadcast_btn; + case 3: + return g_contacts_state.team_btn; + case 4: + return g_contacts_state.discover_btn; + default: + return nullptr; } } -static void bind_list_column() +static int get_preferred_filter_index(void* /*ctx*/) { - if (!s_group) return; - group_clear_all(s_group); - clear_action_focus_states(); + return mode_to_index(contacts::ui::g_contacts_state.current_mode); +} + +static size_t get_list_count(void* /*ctx*/) +{ + return contacts::ui::g_contacts_state.list_items.size(); +} + +static lv_obj_t* get_list_button(void* /*ctx*/, size_t index) +{ + auto& items = contacts::ui::g_contacts_state.list_items; + return (index < items.size()) ? items[index] : nullptr; +} + +static int get_preferred_list_index(void* /*ctx*/) +{ + const int selected = contacts::ui::g_contacts_state.selected_index; + return selected >= 0 ? selected : -1; +} + +static lv_obj_t* get_list_back_button(void* /*ctx*/) +{ + return contacts::ui::g_contacts_state.back_btn; +} + +static bool handle_list_enter(void* /*ctx*/, lv_obj_t* focused) +{ + using namespace contacts::ui; + if (!focused || !lv_obj_is_valid(focused)) + { + return false; + } + + if (focused == g_contacts_state.prev_btn || focused == g_contacts_state.next_btn) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return true; + } for (auto* item : g_contacts_state.list_items) { - if (is_visible(item)) + if (focused == item) { - lv_group_add_obj(s_group, item); - attach_key_handler(item); + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return true; } } - - if (is_valid(g_contacts_state.prev_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.prev_btn); - attach_key_handler(g_contacts_state.prev_btn); - } - if (is_valid(g_contacts_state.next_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.next_btn); - attach_key_handler(g_contacts_state.next_btn); - } - if (is_valid(g_contacts_state.back_btn)) - { - lv_group_add_obj(s_group, g_contacts_state.back_btn); - attach_key_handler(g_contacts_state.back_btn); - } - - if (!g_contacts_state.list_items.empty() && is_valid(g_contacts_state.list_items[0])) - { - focus_first_valid(g_contacts_state.list_items[0]); - } - else if (is_valid(g_contacts_state.back_btn)) - { - focus_first_valid(g_contacts_state.back_btn); - } - else if (is_valid(g_contacts_state.prev_btn)) - { - focus_first_valid(g_contacts_state.prev_btn); - } - else if (is_valid(g_contacts_state.next_btn)) - { - focus_first_valid(g_contacts_state.next_btn); - } - - CONTACTS_LOG("[Contacts][Input] bind_list_column (items=%u)\n", - (unsigned)g_contacts_state.list_items.size()); + return false; } -static void rebind_by_column() +static Adapter make_adapter() { - switch (s_col) - { - case FocusColumn::Filter: - bind_filter_column(true); - break; - case FocusColumn::List: - bind_list_column(); - break; - } -} - -/** - * Encoder key handler (SPEC): - * - Filter: ENTER -> List - * - List: ENTER on item -> open action popup (or execute Discover action row) - * ENTER on Prev/Next -> click (stay List) - * ENTER on Back -> Filter - * - ESC/BACKSPACE: List -> Filter - */ -static void root_key_event_cb(lv_event_t* e) -{ - uint32_t key = lv_event_get_key(e); - - if (key == LV_KEY_BACKSPACE) - { - if (is_valid(g_contacts_state.top_bar.back_btn)) - { - lv_obj_send_event(g_contacts_state.top_bar.back_btn, LV_EVENT_CLICKED, nullptr); - } - return; - } - - if (!is_encoder_active()) return; - - // Back one column - if (key == LV_KEY_ESC) - { - if (s_col == FocusColumn::List) s_col = FocusColumn::Filter; - else s_col = FocusColumn::Filter; - rebind_by_column(); - return; - } - - if (key != LV_KEY_ENTER) return; - - lv_obj_t* focused = s_group ? lv_group_get_focused(s_group) : nullptr; - - if (s_col == FocusColumn::Filter) - { - s_col = FocusColumn::List; - rebind_by_column(); - return; - } - - if (s_col == FocusColumn::List) - { - if (focused == g_contacts_state.back_btn && is_valid(g_contacts_state.back_btn)) - { - s_col = FocusColumn::Filter; - rebind_by_column(); - return; - } - - if (focused == g_contacts_state.prev_btn && is_valid(g_contacts_state.prev_btn)) - { - if (g_contacts_state.prev_btn) lv_obj_send_event(g_contacts_state.prev_btn, LV_EVENT_CLICKED, nullptr); - if (g_contacts_state.prev_btn) focus_first_valid(g_contacts_state.prev_btn); - return; - } - if (focused == g_contacts_state.next_btn && is_valid(g_contacts_state.next_btn)) - { - if (g_contacts_state.next_btn) lv_obj_send_event(g_contacts_state.next_btn, LV_EVENT_CLICKED, nullptr); - if (g_contacts_state.next_btn) focus_first_valid(g_contacts_state.next_btn); - return; - } - - for (size_t i = 0; i < g_contacts_state.list_items.size(); ++i) - { - if (focused == g_contacts_state.list_items[i]) - { - lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); - return; - } - } - return; - } + Adapter adapter{}; + adapter.get_key_target = get_key_target; + adapter.get_top_back_button = get_top_back_button; + adapter.get_filter_count = get_filter_count; + adapter.get_filter_button = get_filter_button; + adapter.get_preferred_filter_index = get_preferred_filter_index; + adapter.get_list_count = get_list_count; + adapter.get_list_button = get_list_button; + adapter.get_preferred_list_index = get_preferred_list_index; + adapter.get_list_back_button = get_list_back_button; + adapter.handle_list_enter = handle_list_enter; + adapter.filter_top_back_placement = BackPlacement::Trailing; + return adapter; } } // namespace void init_contacts_input() { - if (s_group) - { - cleanup_contacts_input(); - } - s_group = lv_group_create(); - s_prev_group = lv_group_get_default(); - set_default_group(nullptr); - set_default_group(s_group); - - s_encoder = find_encoder_indev(); - if (s_encoder) - { - lv_indev_set_group(s_encoder, s_group); - } - else - { - CONTACTS_LOG("[Contacts][Input] WARNING: no encoder indev found\n"); - } - s_col = FocusColumn::Filter; - rebind_by_column(); - - lv_obj_t* key_target = g_contacts_state.root - ? g_contacts_state.root - : (g_contacts_state.list_panel ? g_contacts_state.list_panel - : g_contacts_state.filter_panel); - - if (is_valid(key_target)) - { - lv_obj_add_event_cb(key_target, root_key_event_cb, LV_EVENT_KEY, nullptr); - } - - CONTACTS_LOG("[Contacts][Input] initialized: start in Filter column\n"); + ::ui::components::two_pane_nav::init(&s_binding, make_adapter()); } void cleanup_contacts_input() { - if (s_group) - { - - // Only detach the encoder we attached to this group. - if (s_encoder && lv_indev_get_group(s_encoder) == s_group) - { - lv_indev_set_group(s_encoder, nullptr); - } - - set_default_group(nullptr); - lv_group_del(s_group); - s_group = nullptr; - } - - s_encoder = nullptr; - if (s_prev_group) - { - set_default_group(s_prev_group); - s_prev_group = nullptr; - } - - CONTACTS_LOG("[Contacts][Input] cleaned up\n"); + ::ui::components::two_pane_nav::cleanup(&s_binding); } void contacts_input_on_ui_refreshed() { - if (!s_group) return; - rebind_by_column(); + ::ui::components::two_pane_nav::on_ui_refreshed(&s_binding); } void contacts_focus_to_filter() { - if (!s_group) return; - clear_action_focus_states(); - s_col = FocusColumn::Filter; - rebind_by_column(); + ::ui::components::two_pane_nav::focus_filter(&s_binding); } void contacts_focus_to_list() { - if (!s_group) return; - clear_action_focus_states(); - s_col = FocusColumn::List; - rebind_by_column(); -} - -void contacts_focus_to_action() -{ - if (!s_group) return; - // Action panel removed; keep API compatibility by mapping to list. - s_col = FocusColumn::List; - rebind_by_column(); + ::ui::components::two_pane_nav::focus_list(&s_binding); } lv_group_t* contacts_input_get_group() { - return s_group; + return ::ui::components::two_pane_nav::get_group(&s_binding); } diff --git a/src/ui/screens/contacts/contacts_page_input.h b/src/ui/screens/contacts/contacts_page_input.h index f9ae585b..3c3c43b7 100644 --- a/src/ui/screens/contacts/contacts_page_input.h +++ b/src/ui/screens/contacts/contacts_page_input.h @@ -1,39 +1,26 @@ /** * @file contacts_page_input.h - * @brief Contacts page input handling (Rotary encoder navigation) + * @brief Contacts page input handling for the shared two-pane navigator */ #pragma once #include "lvgl.h" -/** - * @brief Initialize input handling (create lv_group, default focus on Filter column) - */ void init_contacts_input(); - -/** - * @brief Clean up input handling (delete lv_group) - */ void cleanup_contacts_input(); /** - * @brief MUST be called after refresh_ui() rebuilds list/buttons - * Rebind lv_group objects based on current focus column. + * Rebind focusable objects after refresh_ui() rebuilds the current list. */ void contacts_input_on_ui_refreshed(); /** - * @brief Switch focus column according to Contacts UI spec. - * - Filter: only Contacts/Nearby buttons - * - List: list items + Prev/Next/Back + * Move focus to the filter column or the list column. + * The filter column contains the visible mode buttons plus the top back button. + * The list column contains list items, the inline list Back item, and optional pager buttons. */ void contacts_focus_to_filter(); void contacts_focus_to_list(); -// Kept for compatibility with existing callers; currently maps to list focus. -void contacts_focus_to_action(); -/** - * @brief Get the current input group for Contacts page (may be nullptr if not initialized) - */ lv_group_t* contacts_input_get_group(); diff --git a/src/ui/screens/contacts/contacts_page_layout.cpp b/src/ui/screens/contacts/contacts_page_layout.cpp index bab57f97..d47a0588 100644 --- a/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/src/ui/screens/contacts/contacts_page_layout.cpp @@ -1,45 +1,3 @@ -/** - * UI Wireframe / Layout Tree (Correct: 3 columns in one ROW) - * -------------------------------------------------------------------- - * - * Root Container (ROW, full screen) - * - * ┌──────────────────────────────────────────────────────────────────┠- * │ ┌──────────────┠┌───────────────────────────────┠┌──────────────┠│ - * │ │ Filter Panel │ │ List Panel │ │ Action Panel │ │ - * │ │ (80px) │ │ (flex-grow = 1) │ │ (80px) │ │ - * │ │ ┌──────────┠│ │ ┌───────────────────────────┠│ │ │ │ - * │ │ │ Contacts │ │ │ │ List Container │ │ │ (context │ │ - * │ │ └──────────┘ │ │ │ (COLUMN, flex-grow = 1) │ │ │ actions) │ │ - * │ │ ┌──────────┠│ │ │ │ │ │ │ │ - * │ │ │ Nearby │ │ │ │ List Item × 4 / page │ │ │ │ │ - * │ │ └──────────┘ │ │ │ [Name ..........] [St.] │ │ │ │ │ - * │ │ │ │ │ ... │ │ │ │ │ - * │ │ │ │ └───────────────────────────┘ │ │ │ │ - * │ │ │ │ ┌───────────────────────────┠│ │ │ │ - * │ │ │ │ │ Bottom Bar (ROW) │ │ │ │ │ - * │ │ │ │ │ Prev | Next | Back │ │ │ │ │ - * │ │ │ │ └───────────────────────────┘ │ │ │ │ - * │ └──────────────┘ └───────────────────────────────┘ └───────────┘ │ - * └──────────────────────────────────────────────────────────────────┘ - * - * Tree view: - * Root(ROW) - * ├─ FilterPanel(80,COL) -> ContactsBtn, NearbyBtn - * ├─ ListPanel(grow=1,COL) - * │ ├─ ListContainer(grow=1,COL) -> ListItem(x4/page)-> NameLabel, StatusLabel - * │ └─ BottomBar(ROW) -> PrevBtn, NextBtn, BackBtn - * └─ ActionPanel(80,COL) -> (TBD action buttons) - * - * - * Preconditions: - * - The parent/root container uses LV_FLEX_FLOW_ROW to place 3 panels horizontally. - * - * Implementation notes: - * - ListPanel contains two children: sub_container (flex_grow=1) and bottom_container (ROW). - * - ActionPanel is a placeholder; action buttons are created elsewhere. - */ - /** * @file contacts_page_layout.cpp * @brief Contacts layout @@ -49,6 +7,7 @@ #include "../../../app/app_context.h" #include "../../../chat/domain/chat_types.h" #include "../../../chat/infra/meshtastic/mt_region.h" +#include "../../components/two_pane_layout.h" #include using namespace contacts::ui; @@ -62,31 +21,12 @@ namespace layout // Layout constants static constexpr int kFilterPanelWidth = 80; -static constexpr int kActionPanelWidth = 80; static constexpr int kButtonHeight = 28; static constexpr int kButtonSpacing = 3; -static constexpr int kPanelGap = 3; // Gap between the three main columns +static constexpr int kPanelGap = 3; // Gap between filter and list columns static constexpr int kScreenEdgePadding = 3; // Padding at the screen edge static constexpr int kTopBarContentGap = 3; // Vertical gap between TopBar and content -// UI color tokens (must align with docs/skyplot.md) -static constexpr uint32_t kColorWarmBg = 0xF6E6C6; - -// Helper functions -static void make_non_scrollable(lv_obj_t* obj) -{ - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); -} - -static void apply_base_container_style(lv_obj_t* obj) -{ - lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_radius(obj, 0, 0); - make_non_scrollable(obj); -} - namespace { @@ -118,34 +58,21 @@ void format_contacts_title(char* out, size_t out_len) lv_obj_t* create_root(lv_obj_t* parent) { - lv_obj_t* root = lv_obj_create(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - - // Style: transparent background so children control their own background color. - lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, 0); - apply_base_container_style(root); - - // Gap between TopBar and content row. - lv_obj_set_style_pad_row(root, kTopBarContentGap, 0); - lv_obj_set_style_pad_all(root, 0, 0); - - return root; + ::ui::components::two_pane_layout::RootSpec spec; + spec.pad_row = kTopBarContentGap; + return ::ui::components::two_pane_layout::create_root(parent, spec); } lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user_data) { - lv_obj_t* header = lv_obj_create(root); - lv_obj_set_size(header, LV_PCT(100), ::ui::widgets::kTopBarHeight); + ::ui::components::two_pane_layout::HeaderSpec header_spec; + header_spec.height = ::ui::widgets::kTopBarHeight; + header_spec.bg_hex = ::ui::components::two_pane_styles::kSidePanelBg; + header_spec.pad_all = 0; + lv_obj_t* header = ::ui::components::two_pane_layout::create_header_container(root, header_spec); - // Style for header / TopBar container. - lv_obj_set_style_bg_color(header, lv_color_hex(kColorWarmBg), 0); - apply_base_container_style(header); - lv_obj_set_style_pad_all(header, 0, 0); - - // Initialize shared TopBar widget. ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::widgets::kTopBarHeight; ::ui::widgets::top_bar_init(g_contacts_state.top_bar, header, cfg); @@ -159,84 +86,56 @@ lv_obj_t* create_header(lv_obj_t* root, lv_obj_t* create_content(lv_obj_t* root) { - lv_obj_t* content = lv_obj_create(root); - - // Size and flex setup for main content row. - lv_obj_set_width(content, LV_PCT(100)); - lv_obj_set_height(content, 0); - lv_obj_set_flex_grow(content, 1); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(content, - LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START); - - // Style - lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); - apply_base_container_style(content); - - // Use only outer padding to control spacing to screen edges. - // Horizontal gaps between columns are controlled via panel margins. - lv_obj_set_style_pad_left(content, kScreenEdgePadding, 0); - lv_obj_set_style_pad_right(content, kScreenEdgePadding, 0); - lv_obj_set_style_pad_top(content, 0, 0); - lv_obj_set_style_pad_bottom(content, 0, 0); - - return content; + ::ui::components::two_pane_layout::ContentSpec spec; + spec.pad_left = kScreenEdgePadding; + spec.pad_right = kScreenEdgePadding; + spec.pad_top = 0; + spec.pad_bottom = 0; + return ::ui::components::two_pane_layout::create_content_row(root, spec); } void create_filter_panel(lv_obj_t* parent) { - g_contacts_state.filter_panel = lv_obj_create(parent); - make_non_scrollable(g_contacts_state.filter_panel); + ::ui::components::two_pane_layout::SidePanelSpec panel_spec; + panel_spec.width = kFilterPanelWidth; + panel_spec.pad_row = kButtonSpacing; + panel_spec.margin_left = 0; + panel_spec.margin_right = kPanelGap; + g_contacts_state.filter_panel = ::ui::components::two_pane_layout::create_side_panel(parent, panel_spec); - // Apply style first so it does not override later size/margin tweaks. style::apply_panel_side(g_contacts_state.filter_panel); - // Fixed width for filter panel. - lv_obj_set_width(g_contacts_state.filter_panel, kFilterPanelWidth); - lv_obj_set_height(g_contacts_state.filter_panel, LV_PCT(100)); - lv_obj_set_flex_flow(g_contacts_state.filter_panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(g_contacts_state.filter_panel, 3, LV_PART_MAIN); - - // Use margins for inter-panel spacing instead of negative margins. - // Filter is the left-most panel, so margin_left=0 (screen-edge padding is on content). - // Filter's right margin creates the gap to the List panel. - lv_obj_set_style_margin_left(g_contacts_state.filter_panel, 0, LV_PART_MAIN); - lv_obj_set_style_margin_right(g_contacts_state.filter_panel, kPanelGap, LV_PART_MAIN); - - // Buttons g_contacts_state.contacts_btn = lv_btn_create(g_contacts_state.filter_panel); - make_non_scrollable(g_contacts_state.contacts_btn); - style::apply_btn_filter(g_contacts_state.contacts_btn); lv_obj_set_size(g_contacts_state.contacts_btn, LV_PCT(100), kButtonHeight); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.contacts_btn); + style::apply_btn_filter(g_contacts_state.contacts_btn); lv_obj_t* contacts_label = lv_label_create(g_contacts_state.contacts_btn); lv_label_set_text(contacts_label, "Contacts"); style::apply_label_primary(contacts_label); lv_obj_center(contacts_label); g_contacts_state.nearby_btn = lv_btn_create(g_contacts_state.filter_panel); - make_non_scrollable(g_contacts_state.nearby_btn); - style::apply_btn_filter(g_contacts_state.nearby_btn); lv_obj_set_size(g_contacts_state.nearby_btn, LV_PCT(100), kButtonHeight); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.nearby_btn); + style::apply_btn_filter(g_contacts_state.nearby_btn); lv_obj_t* nearby_label = lv_label_create(g_contacts_state.nearby_btn); lv_label_set_text(nearby_label, "Nearby"); style::apply_label_primary(nearby_label); lv_obj_center(nearby_label); g_contacts_state.broadcast_btn = lv_btn_create(g_contacts_state.filter_panel); - make_non_scrollable(g_contacts_state.broadcast_btn); - style::apply_btn_filter(g_contacts_state.broadcast_btn); lv_obj_set_size(g_contacts_state.broadcast_btn, LV_PCT(100), kButtonHeight); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.broadcast_btn); + style::apply_btn_filter(g_contacts_state.broadcast_btn); lv_obj_t* broadcast_label = lv_label_create(g_contacts_state.broadcast_btn); lv_label_set_text(broadcast_label, "Broadcast"); style::apply_label_primary(broadcast_label); lv_obj_center(broadcast_label); g_contacts_state.team_btn = lv_btn_create(g_contacts_state.filter_panel); - make_non_scrollable(g_contacts_state.team_btn); - style::apply_btn_filter(g_contacts_state.team_btn); lv_obj_set_size(g_contacts_state.team_btn, LV_PCT(100), kButtonHeight); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.team_btn); + style::apply_btn_filter(g_contacts_state.team_btn); lv_obj_t* team_label = lv_label_create(g_contacts_state.team_btn); lv_label_set_text(team_label, "Team"); style::apply_label_primary(team_label); @@ -244,13 +143,14 @@ void create_filter_panel(lv_obj_t* parent) lv_obj_add_flag(g_contacts_state.team_btn, LV_OBJ_FLAG_HIDDEN); g_contacts_state.discover_btn = lv_btn_create(g_contacts_state.filter_panel); - make_non_scrollable(g_contacts_state.discover_btn); - style::apply_btn_filter(g_contacts_state.discover_btn); lv_obj_set_size(g_contacts_state.discover_btn, LV_PCT(100), kButtonHeight); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.discover_btn); + style::apply_btn_filter(g_contacts_state.discover_btn); lv_obj_t* discover_label = lv_label_create(g_contacts_state.discover_btn); lv_label_set_text(discover_label, "Discover"); style::apply_label_primary(discover_label); lv_obj_center(discover_label); + if (app::AppContext::getInstance().getConfig().mesh_protocol != chat::MeshProtocol::MeshCore) { lv_obj_add_flag(g_contacts_state.discover_btn, LV_OBJ_FLAG_HIDDEN); @@ -259,42 +159,15 @@ void create_filter_panel(lv_obj_t* parent) void create_list_panel(lv_obj_t* parent) { - g_contacts_state.list_panel = lv_obj_create(parent); - make_non_scrollable(g_contacts_state.list_panel); + ::ui::components::two_pane_layout::MainPanelSpec panel_spec; + panel_spec.pad_all = 0; + panel_spec.pad_row = 2; + panel_spec.margin_left = 0; + panel_spec.margin_right = 0; + panel_spec.scrollbar_mode = LV_SCROLLBAR_MODE_OFF; + g_contacts_state.list_panel = ::ui::components::two_pane_layout::create_main_panel(parent, panel_spec); - // Apply style first so it does not override our grow/width settings. style::apply_panel_main(g_contacts_state.list_panel); - - // In a ROW flex layout, the middle column must have width=0 + flex_grow=1 to consume remaining space. - lv_obj_set_height(g_contacts_state.list_panel, LV_PCT(100)); - lv_obj_set_width(g_contacts_state.list_panel, 0); - lv_obj_set_flex_grow(g_contacts_state.list_panel, 1); - - lv_obj_set_flex_flow(g_contacts_state.list_panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(g_contacts_state.list_panel, 2, LV_PART_MAIN); - - // Middle column itself has no horizontal margins; spacing is controlled by filter/action panels. - lv_obj_set_style_margin_left(g_contacts_state.list_panel, 0, LV_PART_MAIN); - lv_obj_set_style_margin_right(g_contacts_state.list_panel, 0, LV_PART_MAIN); -} - -void create_action_panel(lv_obj_t* parent) -{ - g_contacts_state.action_panel = lv_obj_create(parent); - make_non_scrollable(g_contacts_state.action_panel); - - // Apply style first so it does not override size configuration. - style::apply_panel_side(g_contacts_state.action_panel); - - // Fixed width for action panel. - lv_obj_set_width(g_contacts_state.action_panel, kActionPanelWidth); - lv_obj_set_height(g_contacts_state.action_panel, LV_PCT(100)); - lv_obj_set_flex_flow(g_contacts_state.action_panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(g_contacts_state.action_panel, kButtonSpacing, LV_PART_MAIN); - - // Horizontal spacing: left margin creates the gap to the List panel; right edge uses content padding. - lv_obj_set_style_margin_left(g_contacts_state.action_panel, kPanelGap, LV_PART_MAIN); - lv_obj_set_style_margin_right(g_contacts_state.action_panel, 0, LV_PART_MAIN); } void ensure_list_subcontainers() @@ -304,7 +177,7 @@ void ensure_list_subcontainers() if (g_contacts_state.sub_container == nullptr) { g_contacts_state.sub_container = lv_obj_create(g_contacts_state.list_panel); - make_non_scrollable(g_contacts_state.sub_container); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.sub_container); style::apply_container_white(g_contacts_state.sub_container); @@ -320,7 +193,7 @@ void ensure_list_subcontainers() if (g_contacts_state.bottom_container == nullptr) { g_contacts_state.bottom_container = lv_obj_create(g_contacts_state.list_panel); - make_non_scrollable(g_contacts_state.bottom_container); + ::ui::components::two_pane_layout::make_non_scrollable(g_contacts_state.bottom_container); style::apply_container_white(g_contacts_state.bottom_container); @@ -343,7 +216,7 @@ lv_obj_t* create_list_item(lv_obj_t* parent, lv_obj_set_size(item, LV_PCT(100), 28); lv_obj_add_flag(item, LV_OBJ_FLAG_CLICKABLE); - make_non_scrollable(item); + ::ui::components::two_pane_layout::make_non_scrollable(item); style::apply_list_item(item); diff --git a/src/ui/screens/contacts/contacts_page_layout.h b/src/ui/screens/contacts/contacts_page_layout.h index e73e1a49..3cae4977 100644 --- a/src/ui/screens/contacts/contacts_page_layout.h +++ b/src/ui/screens/contacts/contacts_page_layout.h @@ -1,7 +1,7 @@ #pragma once -#include "contacts_page_styles.h" // style::apply_* -#include "contacts_state.h" // g_contacts_state, ContactsMode +#include "contacts_page_styles.h" +#include "contacts_state.h" #include "lvgl.h" namespace contacts @@ -11,60 +11,34 @@ namespace ui namespace layout { -/** - * @brief Create root container for contacts page - * @param parent Parent object (usually main screen) - * @return Created root container - */ lv_obj_t* create_root(lv_obj_t* parent); +lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user_data); /** - * @brief Create header container with top bar - * @param root Root container - * @param back_callback Callback for back button - * @param user_data User data for callback - * @return Created header container - */ -lv_obj_t* create_header(lv_obj_t* root, - void (*back_callback)(void*), - void* user_data); - -/** - * @brief Create content container (three columns parent) - * @param root Root container - * @return Created content container + * Create the main two-pane content row for the Contacts page. */ lv_obj_t* create_content(lv_obj_t* root); /** - * @brief Create filter panel (first column) + * Create the left filter column. */ void create_filter_panel(lv_obj_t* parent); /** - * @brief Create list panel (second column) + * Create the main list column. */ void create_list_panel(lv_obj_t* parent); /** - * @brief Create action panel (third column) - */ -void create_action_panel(lv_obj_t* parent); - -/** - * @brief Ensure list sub containers exist (sub_container + bottom_container) - * Called from refresh_ui() before creating list items / buttons. + * Ensure the list subcontainers exist. + * sub_container holds the list rows; bottom_container is used only when a mode still needs pager buttons. */ void ensure_list_subcontainers(); -/** - * @brief Create one list item (row) under parent, and push into g_contacts_state.list_items. - * @return the created item object. - */ lv_obj_t* create_list_item(lv_obj_t* parent, const chat::contacts::NodeInfo& node, ContactsMode mode, - const char* status_text /* already formatted */); + const char* status_text); } // namespace layout } // namespace ui diff --git a/src/ui/screens/contacts/contacts_page_styles.cpp b/src/ui/screens/contacts/contacts_page_styles.cpp index 08d5c38d..23d081a1 100644 --- a/src/ui/screens/contacts/contacts_page_styles.cpp +++ b/src/ui/screens/contacts/contacts_page_styles.cpp @@ -1,171 +1,52 @@ #include "contacts_page_styles.h" +#include "../../components/two_pane_styles.h" namespace contacts::ui::style { -// ---------- Colors (must align with docs/skyplot.md tokens) ---------- -static constexpr uint32_t kGrayPanel = 0xF6E6C6; // WarmBG -static constexpr uint32_t kWhite = 0xFAF0D8; // PanelBG - -static constexpr uint32_t kBtnBg = 0xFAF0D8; // PanelBG -static constexpr uint32_t kBtnBgSel = 0xEBA341; -static constexpr uint32_t kBtnBorder = 0xE7C98F; // Line - -static constexpr uint32_t kItemBg = 0xFAF0D8; -static constexpr uint32_t kItemBgFoc = 0xEBA341; -static constexpr uint32_t kItemBorder = 0xE7C98F; - -static constexpr uint32_t kTextMain = 0x6B4A1E; // Text -static constexpr uint32_t kTextMuted = 0x8A6A3A; // TextDim - -// ---------- Styles ---------- -static bool s_inited = false; - -static lv_style_t s_panel_side; -static lv_style_t s_panel_main; -static lv_style_t s_container_white; - -static lv_style_t s_btn_basic; -static lv_style_t s_btn_filter_checked; // applied via LV_STATE_CHECKED -static lv_style_t s_btn_filter_focused; // applied via LV_STATE_FOCUSED/FOCUS_KEY - -static lv_style_t s_item_base; -static lv_style_t s_item_focused; // applied via LV_STATE_FOCUSED - -static lv_style_t s_label_primary; -static lv_style_t s_label_muted; - void init_once() { - if (s_inited) return; - s_inited = true; - - // ---- Side panel (filter/action): gray background, no border, pad=3 ---- - lv_style_init(&s_panel_side); - lv_style_set_bg_opa(&s_panel_side, LV_OPA_COVER); - lv_style_set_bg_color(&s_panel_side, lv_color_hex(kGrayPanel)); - lv_style_set_border_width(&s_panel_side, 0); - lv_style_set_pad_all(&s_panel_side, 3); - lv_style_set_radius(&s_panel_side, 0); - - // ---- Main panel (list): white background, no border, pad=3 ---- - lv_style_init(&s_panel_main); - lv_style_set_bg_opa(&s_panel_main, LV_OPA_COVER); - lv_style_set_bg_color(&s_panel_main, lv_color_hex(kWhite)); - lv_style_set_border_width(&s_panel_main, 0); - lv_style_set_pad_all(&s_panel_main, 3); - lv_style_set_radius(&s_panel_main, 0); - - // ---- White containers (sub/bottom): white background, no border, pad=3 ---- - lv_style_init(&s_container_white); - lv_style_set_bg_opa(&s_container_white, LV_OPA_COVER); - lv_style_set_bg_color(&s_container_white, lv_color_hex(kWhite)); - lv_style_set_border_width(&s_container_white, 0); - lv_style_set_pad_all(&s_container_white, 3); - lv_style_set_radius(&s_container_white, 0); - - // ---- Buttons (common): gray bg, border=1, radius=12 ---- - lv_style_init(&s_btn_basic); - lv_style_set_bg_opa(&s_btn_basic, LV_OPA_COVER); - lv_style_set_bg_color(&s_btn_basic, lv_color_hex(kBtnBg)); - lv_style_set_border_width(&s_btn_basic, 1); - lv_style_set_border_color(&s_btn_basic, lv_color_hex(kBtnBorder)); - lv_style_set_radius(&s_btn_basic, 12); - lv_style_set_text_color(&s_btn_basic, lv_color_hex(kTextMain)); - - // ---- Filter selected state: when CHECKED, use darker bg ---- - lv_style_init(&s_btn_filter_checked); - lv_style_set_bg_opa(&s_btn_filter_checked, LV_OPA_COVER); - lv_style_set_bg_color(&s_btn_filter_checked, lv_color_hex(kBtnBgSel)); - - // ---- Filter focused state: encoder/keyboard focus highlight ---- - // Keep same token direction as CHECKED so Discover (non-mode button) also - // gets visible highlight when focus moves onto it. - lv_style_init(&s_btn_filter_focused); - lv_style_set_bg_opa(&s_btn_filter_focused, LV_OPA_COVER); - lv_style_set_bg_color(&s_btn_filter_focused, lv_color_hex(kBtnBgSel)); - - // ---- List item base ---- - lv_style_init(&s_item_base); - lv_style_set_bg_opa(&s_item_base, LV_OPA_COVER); - lv_style_set_bg_color(&s_item_base, lv_color_hex(kItemBg)); - lv_style_set_border_width(&s_item_base, 1); - lv_style_set_border_color(&s_item_base, lv_color_hex(kItemBorder)); - lv_style_set_radius(&s_item_base, 6); - - // ---- List item focused state ---- - lv_style_init(&s_item_focused); - lv_style_set_bg_opa(&s_item_focused, LV_OPA_COVER); - lv_style_set_bg_color(&s_item_focused, lv_color_hex(kItemBgFoc)); - lv_style_set_outline_width(&s_item_focused, 0); - - // ---- Labels ---- - lv_style_init(&s_label_primary); - lv_style_set_text_color(&s_label_primary, lv_color_hex(kTextMain)); - - lv_style_init(&s_label_muted); - lv_style_set_text_color(&s_label_muted, lv_color_hex(kTextMuted)); + ::ui::components::two_pane_styles::init_once(); } -// ---------------- Apply functions ---------------- - void apply_panel_side(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_panel_side, 0); + ::ui::components::two_pane_styles::apply_panel_side(obj); } void apply_panel_main(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_panel_main, 0); + ::ui::components::two_pane_styles::apply_panel_main(obj); } void apply_container_white(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_container_white, 0); + ::ui::components::two_pane_styles::apply_container_main(obj); } void apply_btn_basic(lv_obj_t* btn) { - init_once(); - lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); + ::ui::components::two_pane_styles::apply_btn_basic(btn); } void apply_btn_filter(lv_obj_t* btn) { - init_once(); - // base appearance - lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); - // checked highlight - lv_obj_add_style(btn, &s_btn_filter_checked, LV_PART_MAIN | LV_STATE_CHECKED); - // focus highlight (for non-checkable action buttons like Discover) - lv_obj_add_style(btn, &s_btn_filter_focused, LV_PART_MAIN | LV_STATE_FOCUSED); - lv_obj_add_style(btn, &s_btn_filter_focused, LV_PART_MAIN | LV_STATE_FOCUS_KEY); - - // Optional: make it behave like a checkable item when you toggle state in refresh_ui - // (doesn't change behavior unless you set/clear LV_STATE_CHECKED) - // lv_obj_add_flag(btn, LV_OBJ_FLAG_CHECKABLE); + ::ui::components::two_pane_styles::apply_btn_filter(btn); } void apply_list_item(lv_obj_t* item) { - init_once(); - lv_obj_add_style(item, &s_item_base, LV_PART_MAIN); - lv_obj_add_style(item, &s_item_focused, LV_PART_MAIN | LV_STATE_FOCUSED); + ::ui::components::two_pane_styles::apply_list_item(item); } void apply_label_primary(lv_obj_t* label) { - init_once(); - lv_obj_add_style(label, &s_label_primary, 0); + ::ui::components::two_pane_styles::apply_label_primary(label); } void apply_label_muted(lv_obj_t* label) { - init_once(); - lv_obj_add_style(label, &s_label_muted, 0); + ::ui::components::two_pane_styles::apply_label_muted(label); } } // namespace contacts::ui::style diff --git a/src/ui/screens/contacts/contacts_page_styles.h b/src/ui/screens/contacts/contacts_page_styles.h index b516ecad..2dc0f9af 100644 --- a/src/ui/screens/contacts/contacts_page_styles.h +++ b/src/ui/screens/contacts/contacts_page_styles.h @@ -8,7 +8,7 @@ namespace contacts::ui::style void init_once(); /* Panels */ -void apply_panel_side(lv_obj_t* obj); // filter_panel / action_panel (gray) +void apply_panel_side(lv_obj_t* obj); // filter panel background void apply_panel_main(lv_obj_t* obj); // list_panel (white) void apply_container_white(lv_obj_t* obj); // sub_container / bottom_container (white) diff --git a/src/ui/screens/contacts/contacts_state.h b/src/ui/screens/contacts/contacts_state.h index 63651120..a199f843 100644 --- a/src/ui/screens/contacts/contacts_state.h +++ b/src/ui/screens/contacts/contacts_state.h @@ -50,7 +50,7 @@ enum class ContactsMode struct ContactsPageState { lv_obj_t* root = nullptr; - lv_obj_t* page = nullptr; // Content container (second column) + lv_obj_t* page = nullptr; // Main content row container ::ui::widgets::TopBar top_bar; @@ -62,24 +62,14 @@ struct ContactsPageState lv_obj_t* team_btn = nullptr; lv_obj_t* discover_btn = nullptr; - // Second column: Node list + // Main list column lv_obj_t* list_panel = nullptr; - lv_obj_t* sub_container = nullptr; // Container for list items and pagination - lv_obj_t* bottom_container = nullptr; // Container for bottom buttons (Prev/Next/Back) - std::vector list_items; // Contact/Node rows - lv_obj_t* prev_btn = nullptr; - lv_obj_t* next_btn = nullptr; - lv_obj_t* back_btn = nullptr; // Return to first column - - // Third column: Action buttons - lv_obj_t* action_panel = nullptr; - lv_obj_t* chat_btn = nullptr; - lv_obj_t* position_btn = nullptr; - lv_obj_t* edit_btn = nullptr; - lv_obj_t* del_btn = nullptr; - lv_obj_t* add_btn = nullptr; - lv_obj_t* info_btn = nullptr; - lv_obj_t* action_back_btn = nullptr; // third column back (to list) + lv_obj_t* sub_container = nullptr; // Scrollable list content container + lv_obj_t* bottom_container = nullptr; // Auxiliary bottom row for non-scroll modes + std::vector list_items; // Currently visible list rows + lv_obj_t* prev_btn = nullptr; // Optional pager button + lv_obj_t* next_btn = nullptr; // Optional pager button + lv_obj_t* back_btn = nullptr; // Bottom-row back button for non-scroll modes // Current state ContactsMode current_mode = ContactsMode::Contacts; diff --git a/src/ui/screens/settings/settings_page_input.cpp b/src/ui/screens/settings/settings_page_input.cpp index d1f7ffa1..5613f114 100644 --- a/src/ui/screens/settings/settings_page_input.cpp +++ b/src/ui/screens/settings/settings_page_input.cpp @@ -1,258 +1,120 @@ /** * @file settings_page_input.cpp - * @brief Settings input handling implementation + * @brief Settings input handling */ #include "settings_page_input.h" -#include "../../ui_common.h" + +#include "../../components/two_pane_nav.h" #include "settings_state.h" -#include -namespace settings::ui::input +namespace settings +{ +namespace ui +{ +namespace input { - namespace { -enum class FocusColumn -{ - Filter = 0, - List = 1, -}; +using Adapter = ::ui::components::two_pane_nav::Adapter; +using BackPlacement = ::ui::components::two_pane_nav::BackPlacement; -static lv_group_t* s_group = nullptr; -static lv_group_t* s_prev_group = nullptr; -static lv_indev_t* s_encoder = nullptr; -static FocusColumn s_col = FocusColumn::Filter; +static ::ui::components::two_pane_nav::Binding s_binding{}; -static lv_indev_t* find_encoder_indev() +static lv_obj_t* get_key_target(void* /*ctx*/) { - lv_indev_t* indev = nullptr; - while ((indev = lv_indev_get_next(indev)) != nullptr) - { - if (lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER) - { - return indev; - } - } - return nullptr; + return g_state.root ? g_state.root : g_state.list_panel; } -static bool is_encoder_active() +static lv_obj_t* get_top_back_button(void* /*ctx*/) { - lv_indev_t* indev = lv_indev_get_act(); - return indev && lv_indev_get_type(indev) == LV_INDEV_TYPE_ENCODER; + return g_state.top_bar.back_btn; } -static void group_clear_all(lv_group_t* g) +static size_t get_filter_count(void* /*ctx*/) { - if (!g) return; - lv_group_remove_all_objs(g); + return g_state.filter_count; } -static void focus_first_valid(lv_obj_t* obj) +static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) { - if (!s_group || !obj || !lv_obj_is_valid(obj)) return; - lv_group_focus_obj(obj); + return (index < g_state.filter_count) ? g_state.filter_buttons[index] : nullptr; } -static void root_key_event_cb(lv_event_t* e); - -static void attach_key_handler(lv_obj_t* obj) +static int get_preferred_filter_index(void* /*ctx*/) { - if (!obj || !lv_obj_is_valid(obj)) return; - lv_obj_remove_event_cb(obj, root_key_event_cb); - lv_obj_add_event_cb(obj, root_key_event_cb, LV_EVENT_KEY, nullptr); + return g_state.current_category; } -static void bind_filter_column() +static size_t get_list_count(void* /*ctx*/) { - if (!s_group) return; - group_clear_all(s_group); - - if (g_state.top_bar.back_btn) - { - if (lv_obj_is_valid(g_state.top_bar.back_btn)) - { - lv_group_add_obj(s_group, g_state.top_bar.back_btn); - attach_key_handler(g_state.top_bar.back_btn); - } - } - - for (size_t i = 0; i < g_state.filter_count; ++i) - { - if (g_state.filter_buttons[i] && lv_obj_is_valid(g_state.filter_buttons[i])) - { - lv_group_add_obj(s_group, g_state.filter_buttons[i]); - attach_key_handler(g_state.filter_buttons[i]); - } - } - - if (g_state.filter_buttons[g_state.current_category]) - { - focus_first_valid(g_state.filter_buttons[g_state.current_category]); - } - else if (g_state.top_bar.back_btn) - { - focus_first_valid(g_state.top_bar.back_btn); - } + return g_state.item_count; } -static void bind_list_column() +static lv_obj_t* get_list_button(void* /*ctx*/, size_t index) { - if (!s_group) return; - group_clear_all(s_group); - - for (size_t i = 0; i < g_state.item_count; ++i) - { - if (g_state.item_widgets[i].btn && lv_obj_is_valid(g_state.item_widgets[i].btn)) - { - lv_group_add_obj(s_group, g_state.item_widgets[i].btn); - attach_key_handler(g_state.item_widgets[i].btn); - } - } - if (g_state.list_back_btn && lv_obj_is_valid(g_state.list_back_btn)) - { - lv_group_add_obj(s_group, g_state.list_back_btn); - attach_key_handler(g_state.list_back_btn); - } - - if (g_state.item_count > 0 && g_state.item_widgets[0].btn) - { - focus_first_valid(g_state.item_widgets[0].btn); - } - else if (g_state.list_back_btn) - { - focus_first_valid(g_state.list_back_btn); - } - else if (g_state.top_bar.back_btn) - { - focus_first_valid(g_state.top_bar.back_btn); - } + return (index < g_state.item_count) ? g_state.item_widgets[index].btn : nullptr; } -static void rebind_by_column() +static int get_preferred_list_index(void* /*ctx*/) { - if (s_col == FocusColumn::Filter) - { - bind_filter_column(); - } - else - { - bind_list_column(); - } + return -1; } -static void root_key_event_cb(lv_event_t* e) +static lv_obj_t* get_list_back_button(void* /*ctx*/) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_BACKSPACE) - { - if (g_state.top_bar.back_btn) - { - lv_obj_send_event(g_state.top_bar.back_btn, LV_EVENT_CLICKED, nullptr); - } - return; - } + return g_state.list_back_btn; +} - if (!is_encoder_active()) return; - if (key == LV_KEY_ESC) - { - s_col = FocusColumn::Filter; - rebind_by_column(); - return; - } - if (key != LV_KEY_ENTER) return; - - if (s_col == FocusColumn::Filter) - { - s_col = FocusColumn::List; - rebind_by_column(); - return; - } - - if (s_col == FocusColumn::List) - { - lv_obj_t* focused = s_group ? lv_group_get_focused(s_group) : nullptr; - if (focused == g_state.list_back_btn) - { - s_col = FocusColumn::Filter; - rebind_by_column(); - return; - } - } +static Adapter make_adapter() +{ + Adapter adapter{}; + adapter.get_key_target = get_key_target; + adapter.get_top_back_button = get_top_back_button; + adapter.get_filter_count = get_filter_count; + adapter.get_filter_button = get_filter_button; + adapter.get_preferred_filter_index = get_preferred_filter_index; + adapter.get_list_count = get_list_count; + adapter.get_list_button = get_list_button; + adapter.get_preferred_list_index = get_preferred_list_index; + adapter.get_list_back_button = get_list_back_button; + adapter.filter_top_back_placement = BackPlacement::Leading; + return adapter; } } // namespace void init() { - if (s_group) - { - cleanup(); - } - s_group = lv_group_create(); - s_prev_group = lv_group_get_default(); - set_default_group(nullptr); - set_default_group(s_group); - s_encoder = find_encoder_indev(); - if (s_encoder) - { - lv_indev_set_group(s_encoder, s_group); - } - s_col = FocusColumn::Filter; - rebind_by_column(); - - lv_obj_t* key_target = g_state.root ? g_state.root : g_state.list_panel; - if (key_target && lv_obj_is_valid(key_target)) - { - lv_obj_add_event_cb(key_target, root_key_event_cb, LV_EVENT_KEY, nullptr); - } + ::ui::components::two_pane_nav::init(&s_binding, make_adapter()); } void cleanup() { - if (s_group) - { - if (s_encoder && lv_indev_get_group(s_encoder) == s_group) - { - lv_indev_set_group(s_encoder, nullptr); - } - set_default_group(nullptr); - lv_group_del(s_group); - s_group = nullptr; - } - s_encoder = nullptr; - if (s_prev_group) - { - set_default_group(s_prev_group); - s_prev_group = nullptr; - } + ::ui::components::two_pane_nav::cleanup(&s_binding); } void on_ui_refreshed() { - if (!s_group) return; - rebind_by_column(); + ::ui::components::two_pane_nav::on_ui_refreshed(&s_binding); } void focus_to_filter() { - if (!s_group) return; - s_col = FocusColumn::Filter; - rebind_by_column(); + ::ui::components::two_pane_nav::focus_filter(&s_binding); } void focus_to_list() { - if (!s_group) return; - s_col = FocusColumn::List; - rebind_by_column(); + ::ui::components::two_pane_nav::focus_list(&s_binding); } lv_group_t* get_group() { - return s_group; + return ::ui::components::two_pane_nav::get_group(&s_binding); } -} // namespace settings::ui::input +} // namespace input +} // namespace ui +} // namespace settings diff --git a/src/ui/screens/settings/settings_page_layout.cpp b/src/ui/screens/settings/settings_page_layout.cpp index e1c44b29..d0aaaa3f 100644 --- a/src/ui/screens/settings/settings_page_layout.cpp +++ b/src/ui/screens/settings/settings_page_layout.cpp @@ -4,6 +4,7 @@ */ #include "settings_page_layout.h" +#include "../../components/two_pane_layout.h" #include "../../ui_common.h" #include "settings_page_styles.h" #include "settings_state.h" @@ -15,38 +16,20 @@ namespace settings::ui::layout static constexpr int kFilterPanelWidth = 92; static constexpr int kTopBarContentGap = 3; -static void make_non_scrollable(lv_obj_t* obj) -{ - lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); -} - -static void apply_base_container_style(lv_obj_t* obj) -{ - lv_obj_set_style_border_width(obj, 0, 0); - lv_obj_set_style_radius(obj, 0, 0); - make_non_scrollable(obj); -} - lv_obj_t* create_root(lv_obj_t* parent) { - lv_obj_t* root = lv_obj_create(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, 0); - apply_base_container_style(root); - lv_obj_set_style_pad_row(root, kTopBarContentGap, 0); - lv_obj_set_style_pad_all(root, 0, 0); - return root; + ::ui::components::two_pane_layout::RootSpec spec; + spec.pad_row = kTopBarContentGap; + return ::ui::components::two_pane_layout::create_root(parent, spec); } lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user_data) { - lv_obj_t* header = lv_obj_create(root); - lv_obj_set_size(header, LV_PCT(100), ::ui::widgets::kTopBarHeight); - lv_obj_set_style_bg_color(header, lv_color_hex(0xFFF3DF), 0); - apply_base_container_style(header); - lv_obj_set_style_pad_all(header, 0, 0); + ::ui::components::two_pane_layout::HeaderSpec header_spec; + header_spec.height = ::ui::widgets::kTopBarHeight; + header_spec.bg_hex = ::ui::components::two_pane_styles::kSidePanelBg; + header_spec.pad_all = 0; + lv_obj_t* header = ::ui::components::two_pane_layout::create_header_container(root, header_spec); ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::widgets::kTopBarHeight; @@ -59,60 +42,34 @@ lv_obj_t* create_header(lv_obj_t* root, void (*back_callback)(void*), void* user lv_obj_t* create_content(lv_obj_t* root) { - lv_obj_t* content = lv_obj_create(root); - lv_obj_set_width(content, LV_PCT(100)); - lv_obj_set_height(content, 0); - lv_obj_set_flex_grow(content, 1); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(content, - LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START, - LV_FLEX_ALIGN_START); - - lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); - apply_base_container_style(content); - lv_obj_set_style_pad_left(content, 0, 0); - lv_obj_set_style_pad_right(content, 0, 0); - lv_obj_set_style_pad_top(content, 0, 0); - lv_obj_set_style_pad_bottom(content, 0, 0); - - return content; + return ::ui::components::two_pane_layout::create_content_row(root); } void create_filter_panel(lv_obj_t* parent) { - g_state.filter_panel = lv_obj_create(parent); - make_non_scrollable(g_state.filter_panel); + ::ui::components::two_pane_layout::SidePanelSpec panel_spec; + panel_spec.width = kFilterPanelWidth; + panel_spec.pad_row = 2; + panel_spec.margin_left = 0; + panel_spec.margin_right = 0; + g_state.filter_panel = ::ui::components::two_pane_layout::create_side_panel(parent, panel_spec); style::apply_panel_side(g_state.filter_panel); - lv_obj_set_width(g_state.filter_panel, kFilterPanelWidth); - lv_obj_set_height(g_state.filter_panel, LV_PCT(100)); - lv_obj_set_flex_flow(g_state.filter_panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(g_state.filter_panel, 2, LV_PART_MAIN); - // Keep the seam to the list panel tight so the list gains more usable width. lv_obj_set_style_pad_right(g_state.filter_panel, 1, LV_PART_MAIN); - lv_obj_set_style_margin_left(g_state.filter_panel, 0, LV_PART_MAIN); - lv_obj_set_style_margin_right(g_state.filter_panel, 0, LV_PART_MAIN); } void create_list_panel(lv_obj_t* parent) { - g_state.list_panel = lv_obj_create(parent); - lv_obj_set_scrollbar_mode(g_state.list_panel, LV_SCROLLBAR_MODE_AUTO); + ::ui::components::two_pane_layout::MainPanelSpec panel_spec; + panel_spec.pad_all = 0; + panel_spec.pad_row = 6; + panel_spec.pad_left = 1; + panel_spec.pad_right = 2; + panel_spec.margin_left = 0; + panel_spec.margin_right = 0; + panel_spec.margin_bottom = 3; + panel_spec.scrollbar_mode = LV_SCROLLBAR_MODE_AUTO; + g_state.list_panel = ::ui::components::two_pane_layout::create_main_panel(parent, panel_spec); style::apply_panel_main(g_state.list_panel); - - lv_obj_set_height(g_state.list_panel, LV_PCT(100)); - lv_obj_set_width(g_state.list_panel, 0); - lv_obj_set_flex_grow(g_state.list_panel, 1); - - lv_obj_set_flex_flow(g_state.list_panel, LV_FLEX_FLOW_COLUMN); - // Pull list content closer to the category column and free horizontal room. - lv_obj_set_style_pad_left(g_state.list_panel, 1, LV_PART_MAIN); - lv_obj_set_style_pad_right(g_state.list_panel, 2, LV_PART_MAIN); - lv_obj_set_style_pad_row(g_state.list_panel, 6, LV_PART_MAIN); - lv_obj_set_style_margin_left(g_state.list_panel, 0, LV_PART_MAIN); - // Consume all horizontal space released from the filter panel. - lv_obj_set_style_margin_right(g_state.list_panel, 0, LV_PART_MAIN); - lv_obj_set_style_margin_bottom(g_state.list_panel, 3, LV_PART_MAIN); } } // namespace settings::ui::layout diff --git a/src/ui/screens/settings/settings_page_styles.cpp b/src/ui/screens/settings/settings_page_styles.cpp index 44b02715..0bd9f2f3 100644 --- a/src/ui/screens/settings/settings_page_styles.cpp +++ b/src/ui/screens/settings/settings_page_styles.cpp @@ -4,135 +4,79 @@ */ #include "settings_page_styles.h" +#include "../../components/two_pane_styles.h" namespace settings::ui::style { +namespace +{ -static constexpr uint32_t kPrimary = 0xEBA341; -static constexpr uint32_t kPrimaryLight = 0xEBA341; -static constexpr uint32_t kCardBg = 0xFFF7E9; -static constexpr uint32_t kSoftBg = 0xFFF3DF; -static constexpr uint32_t kTextMain = 0x3A2A1A; -static constexpr uint32_t kTextMuted = 0x6A5646; +bool s_inited = false; +lv_style_t s_modal_bg; +lv_style_t s_modal_panel; +lv_style_t s_modal_btn_checked; -static bool s_inited = false; -static lv_style_t s_panel_side; -static lv_style_t s_panel_main; -static lv_style_t s_btn_basic; -static lv_style_t s_btn_checked; -static lv_style_t s_item_base; -static lv_style_t s_item_focused; -static lv_style_t s_label_primary; -static lv_style_t s_label_muted; -static lv_style_t s_modal_bg; -static lv_style_t s_modal_panel; -static lv_style_t s_modal_btn_checked; +} // namespace void init_once() { + ::ui::components::two_pane_styles::init_once(); if (s_inited) return; s_inited = true; - lv_style_init(&s_panel_side); - lv_style_set_bg_opa(&s_panel_side, LV_OPA_COVER); - lv_style_set_bg_color(&s_panel_side, lv_color_hex(kSoftBg)); - lv_style_set_border_width(&s_panel_side, 0); - lv_style_set_pad_all(&s_panel_side, 4); - - lv_style_init(&s_panel_main); - lv_style_set_bg_opa(&s_panel_main, LV_OPA_COVER); - lv_style_set_bg_color(&s_panel_main, lv_color_hex(kSoftBg)); - lv_style_set_border_width(&s_panel_main, 0); - lv_style_set_pad_all(&s_panel_main, 4); - - lv_style_init(&s_btn_basic); - lv_style_set_bg_opa(&s_btn_basic, LV_OPA_COVER); - lv_style_set_bg_color(&s_btn_basic, lv_color_hex(kCardBg)); - lv_style_set_border_width(&s_btn_basic, 1); - lv_style_set_border_color(&s_btn_basic, lv_color_hex(0xD9B06A)); - lv_style_set_radius(&s_btn_basic, 6); - - lv_style_init(&s_btn_checked); - lv_style_set_bg_opa(&s_btn_checked, LV_OPA_COVER); - lv_style_set_bg_color(&s_btn_checked, lv_color_hex(kPrimary)); - - lv_style_init(&s_item_base); - lv_style_set_bg_opa(&s_item_base, LV_OPA_COVER); - lv_style_set_bg_color(&s_item_base, lv_color_hex(kCardBg)); - lv_style_set_border_width(&s_item_base, 1); - lv_style_set_border_color(&s_item_base, lv_color_hex(0xD9B06A)); - lv_style_set_radius(&s_item_base, 6); - - lv_style_init(&s_item_focused); - lv_style_set_bg_opa(&s_item_focused, LV_OPA_COVER); - lv_style_set_bg_color(&s_item_focused, lv_color_hex(kPrimaryLight)); - lv_style_set_outline_width(&s_item_focused, 2); - lv_style_set_outline_color(&s_item_focused, lv_color_hex(0xD9B06A)); - - lv_style_init(&s_label_primary); - lv_style_set_text_color(&s_label_primary, lv_color_hex(kTextMain)); - - lv_style_init(&s_label_muted); - lv_style_set_text_color(&s_label_muted, lv_color_hex(kTextMuted)); - lv_style_init(&s_modal_bg); lv_style_set_bg_opa(&s_modal_bg, LV_OPA_COVER); - lv_style_set_bg_color(&s_modal_bg, lv_color_hex(kSoftBg)); + lv_style_set_bg_color(&s_modal_bg, + lv_color_hex(::ui::components::two_pane_styles::kSidePanelBg)); lv_style_init(&s_modal_panel); lv_style_set_bg_opa(&s_modal_panel, LV_OPA_COVER); - lv_style_set_bg_color(&s_modal_panel, lv_color_hex(kSoftBg)); + lv_style_set_bg_color(&s_modal_panel, + lv_color_hex(::ui::components::two_pane_styles::kMainPanelBg)); lv_style_set_border_width(&s_modal_panel, 2); - lv_style_set_border_color(&s_modal_panel, lv_color_hex(0xD9B06A)); + lv_style_set_border_color(&s_modal_panel, + lv_color_hex(::ui::components::two_pane_styles::kBorder)); lv_style_set_radius(&s_modal_panel, 8); lv_style_init(&s_modal_btn_checked); lv_style_set_bg_opa(&s_modal_btn_checked, LV_OPA_COVER); - lv_style_set_bg_color(&s_modal_btn_checked, lv_color_hex(kPrimaryLight)); + lv_style_set_bg_color(&s_modal_btn_checked, + lv_color_hex(::ui::components::two_pane_styles::kAccent)); } void apply_panel_side(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_panel_side, 0); + ::ui::components::two_pane_styles::apply_panel_side(obj); } void apply_panel_main(lv_obj_t* obj) { - init_once(); - lv_obj_add_style(obj, &s_panel_main, 0); + ::ui::components::two_pane_styles::apply_panel_main(obj); } void apply_btn_basic(lv_obj_t* btn) { - init_once(); - lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); + ::ui::components::two_pane_styles::apply_btn_basic(btn); } void apply_btn_filter(lv_obj_t* btn) { - init_once(); - lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); - lv_obj_add_style(btn, &s_btn_checked, LV_PART_MAIN | LV_STATE_CHECKED); + ::ui::components::two_pane_styles::apply_btn_filter(btn); } void apply_list_item(lv_obj_t* item) { - init_once(); - lv_obj_add_style(item, &s_item_base, LV_PART_MAIN); - lv_obj_add_style(item, &s_item_focused, LV_PART_MAIN | LV_STATE_FOCUSED); + ::ui::components::two_pane_styles::apply_list_item(item); } void apply_label_primary(lv_obj_t* label) { - init_once(); - lv_obj_add_style(label, &s_label_primary, 0); + ::ui::components::two_pane_styles::apply_label_primary(label); } void apply_label_muted(lv_obj_t* label) { - init_once(); - lv_obj_add_style(label, &s_label_muted, 0); + ::ui::components::two_pane_styles::apply_label_muted(label); } void apply_modal_bg(lv_obj_t* obj) @@ -150,7 +94,7 @@ void apply_modal_panel(lv_obj_t* obj) void apply_btn_modal(lv_obj_t* btn) { init_once(); - lv_obj_add_style(btn, &s_btn_basic, LV_PART_MAIN); + ::ui::components::two_pane_styles::apply_btn_basic(btn); lv_obj_add_style(btn, &s_modal_btn_checked, LV_PART_MAIN | LV_STATE_CHECKED); } diff --git a/src/ui/screens/tracker/tracker_page_components.cpp b/src/ui/screens/tracker/tracker_page_components.cpp index 416e2936..925726db 100644 --- a/src/ui/screens/tracker/tracker_page_components.cpp +++ b/src/ui/screens/tracker/tracker_page_components.cpp @@ -24,22 +24,24 @@ namespace components namespace { constexpr int kFilterPanelWidth = 80; -constexpr int kActionPanelWidth = 80; constexpr int kModeButtonHeight = 28; constexpr int kPrimaryButtonHeight = 28; constexpr int kSecondaryButtonHeight = 28; constexpr int kListItemHeight = 28; constexpr int kBottomBarButtonWidth = 70; -constexpr int kListPageSize = 4; constexpr uintptr_t kListUserDataOffset = 1; +static constexpr intptr_t kBackListItemUserData = -2; constexpr const char* kRouteDir = "/routes"; std::vector s_route_names; std::vector s_record_names; +std::string s_record_empty_text = "No tracks yet"; +std::string s_route_empty_text = "No KML routes"; -constexpr uint32_t kPanelBtnBg = 0xFFF7E9; -constexpr uint32_t kPanelBtnBorder = 0xD9B06A; +constexpr uint32_t kPanelBtnBg = 0xFAF0D8; +constexpr uint32_t kPanelBtnBorder = 0xE7C98F; constexpr uint32_t kPanelBtnFocused = 0xEBA341; -constexpr uint32_t kPanelBtnText = 0x3A2A1A; +constexpr uint32_t kPanelBtnText = 0x6B4A1E; +constexpr uint32_t kPanelTextMuted = 0x8A6A3A; bool s_btn_styles_inited = false; lv_style_t s_btn_main; @@ -47,17 +49,38 @@ lv_style_t s_btn_focused; lv_style_t s_btn_disabled; lv_style_t s_btn_label; +enum class ActionMenuCommand : uintptr_t +{ + Load = 1, + Unload, + Delete, + Cancel, +}; + lv_group_t* tracker_group(); void update_record_status(); void update_start_stop_button(); void update_record_page(); void update_route_status(); void update_route_page(); -void update_del_button(); -void on_action_back_clicked(lv_event_t* e); -lv_obj_t* action_focus_target(); -void focus_action_panel(); +bool can_delete_selected_item(); +bool can_load_selected_route(); +bool can_unload_active_route(); +bool is_any_modal_open(); +void on_route_load_clicked(lv_event_t* e); +void on_route_unload_clicked(lv_event_t* e); +void open_delete_confirm_modal(); +void on_list_item_clicked(lv_event_t* e); +void on_list_item_focused(lv_event_t* e); +void on_list_item_defocused(lv_event_t* e); void on_backspace_key(lv_event_t* e); +lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text); +void on_action_menu_key(lv_event_t* e); +void on_action_menu_item_clicked(lv_event_t* e); +void open_action_menu_modal(); +void clear_list_items(); +lv_obj_t* create_list_item_button(const String& text, intptr_t user_data, bool checked, bool disabled); +void append_back_list_item(); void on_back(void*) { @@ -151,6 +174,12 @@ void modal_close(lv_obj_t*& modal_obj) modal_restore_group(); } +bool is_any_modal_open() +{ + return g_tracker_state.del_confirm_modal != nullptr || + g_tracker_state.action_menu_modal != nullptr; +} + void init_button_styles() { if (s_btn_styles_inited) @@ -163,12 +192,12 @@ void init_button_styles() lv_style_set_border_width(&s_btn_main, 1); lv_style_set_border_color(&s_btn_main, lv_color_hex(kPanelBtnBorder)); lv_style_set_radius(&s_btn_main, 6); + lv_style_set_text_color(&s_btn_main, lv_color_hex(kPanelBtnText)); lv_style_init(&s_btn_focused); lv_style_set_bg_color(&s_btn_focused, lv_color_hex(kPanelBtnFocused)); lv_style_set_bg_opa(&s_btn_focused, LV_OPA_COVER); - lv_style_set_outline_width(&s_btn_focused, 2); - lv_style_set_outline_color(&s_btn_focused, lv_color_hex(kPanelBtnBorder)); + lv_style_set_outline_width(&s_btn_focused, 0); lv_style_init(&s_btn_disabled); lv_style_set_bg_opa(&s_btn_disabled, LV_OPA_50); @@ -219,13 +248,13 @@ void style_mode_button(lv_obj_t* btn, lv_obj_t* label, bool active) { return; } - lv_color_t bg = active ? lv_color_hex(0xEBA341) : lv_color_hex(0xFFF7E9); - lv_color_t fg = lv_color_hex(0x3A2A1A); + lv_color_t bg = active ? lv_color_hex(0xEBA341) : lv_color_hex(0xFAF0D8); + lv_color_t fg = lv_color_hex(0x6B4A1E); lv_obj_set_style_bg_color(btn, bg, LV_PART_MAIN); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); - lv_obj_set_style_border_color(btn, lv_color_hex(0xD9B06A), LV_PART_MAIN); - lv_obj_set_style_radius(btn, 8, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(0xE7C98F), LV_PART_MAIN); + lv_obj_set_style_radius(btn, 12, LV_PART_MAIN); if (label) { lv_obj_set_style_text_color(label, fg, LV_PART_MAIN); @@ -250,27 +279,9 @@ String path_basename(const String& path) return path; } -void update_del_button() +bool can_delete_selected_item() { auto& state = g_tracker_state; - if (!state.del_btn) - { - return; - } - if (state.mode == TrackerPageState::Mode::Route) - { - if (!state.active_route.empty()) - { - lv_obj_add_flag(state.del_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_state(state.del_btn, LV_STATE_DISABLED); - return; - } - lv_obj_clear_flag(state.del_btn, LV_OBJ_FLAG_HIDDEN); - } - else - { - lv_obj_clear_flag(state.del_btn, LV_OBJ_FLAG_HIDDEN); - } bool can_delete = false; if (state.mode == TrackerPageState::Mode::Record) { @@ -297,232 +308,168 @@ void update_del_button() can_delete = true; } } - if (can_delete) - { - lv_obj_clear_state(state.del_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.del_btn, LV_STATE_DISABLED); - } + return can_delete; +} + +bool can_load_selected_route() +{ + auto& state = g_tracker_state; + return state.mode == TrackerPageState::Mode::Route && + state.active_route.empty() && + state.selected_route_idx >= 0 && + !state.selected_route.empty(); +} + +bool can_unload_active_route() +{ + auto& state = g_tracker_state; + return state.mode == TrackerPageState::Mode::Route && + !state.active_route.empty(); } lv_group_t* tracker_group() { + if (lv_group_t* group = tracker::ui::input::tracker_input_get_group()) + { + return group; + } return ::app_g; } -lv_obj_t* action_focus_target() +void clear_list_items() { auto& state = g_tracker_state; - auto visible = [](lv_obj_t* btn) + if (!state.list_container) { - return btn && !lv_obj_has_flag(btn, LV_OBJ_FLAG_HIDDEN); - }; - auto enabled = [](lv_obj_t* btn) + return; + } + while (lv_obj_get_child_count(state.list_container) > 0) { - return btn && !lv_obj_has_flag(btn, LV_OBJ_FLAG_HIDDEN) && - !lv_obj_has_state(btn, LV_STATE_DISABLED); - }; + lv_obj_del(lv_obj_get_child(state.list_container, 0)); + } +} - if (state.mode == TrackerPageState::Mode::Record) +lv_obj_t* create_list_item_button(const String& text, intptr_t user_data, bool checked, bool disabled) +{ + auto& state = g_tracker_state; + if (!state.list_container) { - if (enabled(state.del_btn)) return state.del_btn; - if (visible(state.action_back_btn)) return state.action_back_btn; - if (enabled(state.start_stop_btn)) return state.start_stop_btn; - if (visible(state.del_btn)) return state.del_btn; - if (visible(state.start_stop_btn)) return state.start_stop_btn; return nullptr; } - if (enabled(state.load_btn)) return state.load_btn; - if (enabled(state.unload_btn)) return state.unload_btn; - if (enabled(state.del_btn)) return state.del_btn; - if (visible(state.action_back_btn)) return state.action_back_btn; - if (visible(state.load_btn)) return state.load_btn; - if (visible(state.unload_btn)) return state.unload_btn; - if (visible(state.del_btn)) return state.del_btn; - return nullptr; + lv_obj_t* btn = lv_btn_create(state.list_container); + lv_obj_set_size(btn, LV_PCT(100), kListItemHeight); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + apply_list_button(btn); + + lv_obj_t* label = lv_label_create(btn); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 10, 0); + lv_label_set_text(label, text.c_str()); + lv_obj_add_style(label, &s_btn_label, LV_PART_MAIN); + lv_obj_set_style_text_font(label, &lv_font_noto_cjk_16_2bpp, 0); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + lv_obj_set_width(label, LV_PCT(100)); + + lv_obj_set_user_data(btn, reinterpret_cast(user_data)); + if (checked) + { + lv_obj_add_state(btn, LV_STATE_CHECKED); + } + else + { + lv_obj_clear_state(btn, LV_STATE_CHECKED); + } + if (disabled) + { + lv_obj_add_state(btn, LV_STATE_DISABLED); + } + else + { + lv_obj_clear_state(btn, LV_STATE_DISABLED); + } + + lv_obj_add_event_cb(btn, on_list_item_clicked, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(btn, on_list_item_focused, LV_EVENT_FOCUSED, nullptr); + lv_obj_add_event_cb(btn, on_list_item_defocused, LV_EVENT_DEFOCUSED, nullptr); + return btn; } -void group_add_if(lv_group_t* group, lv_obj_t* obj) +void append_back_list_item() { - if (!group || !obj) + create_list_item_button("Back", kBackListItemUserData, false, false); +} + +void sync_list_item_checked_states() +{ + auto& state = g_tracker_state; + if (!state.list_container) { return; } - if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) + + const int selected_idx = + (state.mode == TrackerPageState::Mode::Record) ? state.selected_record_idx : state.selected_route_idx; + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t index = 0; index < child_count; ++index) { - return; + lv_obj_t* btn = lv_obj_get_child(state.list_container, index); + if (!btn) + { + continue; + } + const intptr_t raw = reinterpret_cast(lv_obj_get_user_data(btn)); + const bool checked = raw >= static_cast(kListUserDataOffset) && + static_cast(raw - static_cast(kListUserDataOffset)) == selected_idx; + if (checked) + { + lv_obj_add_state(btn, LV_STATE_CHECKED); + } + else + { + lv_obj_clear_state(btn, LV_STATE_CHECKED); + } } - if (lv_obj_has_state(obj, LV_STATE_DISABLED)) - { - return; - } - lv_group_add_obj(group, obj); - lv_obj_remove_event_cb(obj, on_backspace_key); - lv_obj_add_event_cb(obj, on_backspace_key, LV_EVENT_KEY, nullptr); } lv_obj_t* first_visible_list_item() { - for (auto* btn : g_tracker_state.list_item_btns) + auto& state = g_tracker_state; + if (!state.list_container) { + return state.start_stop_btn; + } + + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t index = 0; index < child_count; ++index) + { + lv_obj_t* btn = lv_obj_get_child(state.list_container, index); + if (btn && lv_obj_has_state(btn, LV_STATE_CHECKED) && + !lv_obj_has_flag(btn, LV_OBJ_FLAG_HIDDEN) && + !lv_obj_has_state(btn, LV_STATE_DISABLED)) + { + return btn; + } + } + for (uint32_t index = 0; index < child_count; ++index) + { + lv_obj_t* btn = lv_obj_get_child(state.list_container, index); if (btn && !lv_obj_has_flag(btn, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_state(btn, LV_STATE_DISABLED)) { return btn; } } - return g_tracker_state.list_back_btn; -} - -void bind_mode_group() -{ - lv_group_t* group = tracker_group(); - if (!group) - { - return; - } - lv_group_focus_freeze(group, true); - lv_group_remove_all_objs(group); - - group_add_if(group, g_tracker_state.top_bar.back_btn); - group_add_if(group, g_tracker_state.mode_record_btn); - group_add_if(group, g_tracker_state.mode_route_btn); - - lv_group_focus_freeze(group, false); -} - -void bind_main_group() -{ - lv_group_t* group = tracker_group(); - if (!group) - { - return; - } - lv_group_focus_freeze(group, true); - lv_group_remove_all_objs(group); - - group_add_if(group, g_tracker_state.top_bar.back_btn); - for (auto* btn : g_tracker_state.list_item_btns) - { - group_add_if(group, btn); - } - group_add_if(group, g_tracker_state.list_prev_btn); - group_add_if(group, g_tracker_state.list_next_btn); - group_add_if(group, g_tracker_state.list_back_btn); - - if (g_tracker_state.mode == TrackerPageState::Mode::Record) - { - group_add_if(group, g_tracker_state.start_stop_btn); - } - else - { - group_add_if(group, g_tracker_state.load_btn); - group_add_if(group, g_tracker_state.unload_btn); - } - group_add_if(group, g_tracker_state.del_btn); - group_add_if(group, g_tracker_state.action_back_btn); - - lv_group_focus_freeze(group, false); -} - -void focus_mode_panel_async(void*) -{ - if (!g_tracker_state.root) - { - return; - } - if (g_tracker_state.focus_col != TrackerPageState::FocusColumn::Mode) - { - return; - } - bind_mode_group(); - lv_group_t* group = tracker_group(); - if (!group) - { - return; - } - lv_obj_t* target = nullptr; - if (g_tracker_state.mode == TrackerPageState::Mode::Record) - { - target = g_tracker_state.mode_record_btn; - } - else - { - target = g_tracker_state.mode_route_btn; - } - if (target && lv_group_get_focused(group) != target) - { - lv_group_focus_obj(target); - } -} - -void focus_main_panel_async(void*) -{ - if (!g_tracker_state.root) - { - return; - } - if (g_tracker_state.focus_col != TrackerPageState::FocusColumn::Main) - { - return; - } - bind_main_group(); - if (lv_obj_t* target = first_visible_list_item()) - { - lv_group_focus_obj(target); - } -} - -void focus_action_panel_async(void*) -{ - if (!g_tracker_state.root) - { - return; - } - g_tracker_state.focus_col = TrackerPageState::FocusColumn::Main; - bind_main_group(); - lv_group_t* group = tracker_group(); - if (!group) - { - return; - } - if (lv_obj_t* target = action_focus_target()) - { - lv_group_focus_obj(target); - } + return state.start_stop_btn; } void focus_mode_panel() { - g_tracker_state.focus_col = TrackerPageState::FocusColumn::Mode; - lv_async_call(focus_mode_panel_async, nullptr); + tracker::ui::input::tracker_focus_to_filter(); } void focus_main_panel() { - g_tracker_state.focus_col = TrackerPageState::FocusColumn::Main; - lv_async_call(focus_main_panel_async, nullptr); -} - -void focus_action_panel() -{ - g_tracker_state.focus_col = TrackerPageState::FocusColumn::Main; - lv_async_call(focus_action_panel_async, nullptr); -} - -void refresh_focus_group() -{ - if (g_tracker_state.focus_col == TrackerPageState::FocusColumn::Main) - { - bind_main_group(); - } - else - { - bind_mode_group(); - } + tracker::ui::input::tracker_focus_to_list(); } void set_mode(TrackerPageState::Mode mode) @@ -530,6 +477,17 @@ void set_mode(TrackerPageState::Mode mode) auto& state = g_tracker_state; state.mode = mode; update_mode_buttons(); + if (state.bottom_bar) + { + if (mode == TrackerPageState::Mode::Record) + { + lv_obj_clear_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_add_flag(state.bottom_bar, LV_OBJ_FLAG_HIDDEN); + } + } if (state.start_stop_btn) { if (mode == TrackerPageState::Mode::Record) @@ -541,28 +499,6 @@ void set_mode(TrackerPageState::Mode mode) lv_obj_add_flag(state.start_stop_btn, LV_OBJ_FLAG_HIDDEN); } } - if (state.load_btn) - { - if (mode == TrackerPageState::Mode::Route) - { - lv_obj_clear_flag(state.load_btn, LV_OBJ_FLAG_HIDDEN); - } - else - { - lv_obj_add_flag(state.load_btn, LV_OBJ_FLAG_HIDDEN); - } - } - if (state.unload_btn) - { - if (mode == TrackerPageState::Mode::Route) - { - lv_obj_clear_flag(state.unload_btn, LV_OBJ_FLAG_HIDDEN); - } - else - { - lv_obj_add_flag(state.unload_btn, LV_OBJ_FLAG_HIDDEN); - } - } if (mode == TrackerPageState::Mode::Record) { @@ -577,7 +513,7 @@ void set_mode(TrackerPageState::Mode mode) } if (state.focus_col == TrackerPageState::FocusColumn::Main) { - refresh_focus_group(); + tracker::ui::input::tracker_input_on_ui_refreshed(); } } @@ -690,122 +626,41 @@ String format_list_name(const String& name) return utf8_truncate(name, 20); } -void set_placeholder_row(lv_obj_t* btn, lv_obj_t* label, const char* text) -{ - if (!btn || !label) - { - return; - } - lv_label_set_text(label, text); - lv_obj_set_style_text_font(label, &lv_font_noto_cjk_16_2bpp, 0); - lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_state(btn, LV_STATE_DISABLED); - lv_obj_clear_state(btn, LV_STATE_CHECKED); - lv_obj_set_user_data(btn, nullptr); -} - void update_record_page() { auto& state = g_tracker_state; - const size_t total = s_record_names.size(); - int max_page = 0; - if (total > 0) + if (!state.list_container) { - max_page = static_cast((total - 1) / kListPageSize); + return; } - if (state.record_page < 0) state.record_page = 0; - if (state.record_page > max_page) state.record_page = max_page; - const int start = state.record_page * kListPageSize; - if (total == 0) + const lv_coord_t scroll_y = lv_obj_get_scroll_y(state.list_container); + clear_list_items(); + + if (s_record_names.empty()) { - set_placeholder_row(state.list_item_btns[0], state.list_item_labels[0], "No tracks yet"); - for (int i = 1; i < kListPageSize; ++i) - { - if (state.list_item_btns[i]) - { - lv_obj_add_flag(state.list_item_btns[i], LV_OBJ_FLAG_HIDDEN); - } - } + create_list_item_button(s_record_empty_text.c_str(), 0, false, true); } else { - for (int i = 0; i < kListPageSize; ++i) + for (size_t index = 0; index < s_record_names.size(); ++index) { - lv_obj_t* btn = state.list_item_btns[i]; - lv_obj_t* label = state.list_item_labels[i]; - if (!btn || !label) - { - continue; - } - const int idx = start + i; - if (idx < static_cast(total)) - { - String display_name = format_list_name(s_record_names[idx]); - lv_label_set_text(label, display_name.c_str()); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_state(btn, LV_STATE_DISABLED); - if (idx == state.selected_record_idx) - { - lv_obj_add_state(btn, LV_STATE_CHECKED); - } - else - { - lv_obj_clear_state(btn, LV_STATE_CHECKED); - } - lv_obj_set_user_data(btn, - reinterpret_cast((uintptr_t)idx + kListUserDataOffset)); - lv_obj_set_style_text_font(label, &lv_font_noto_cjk_16_2bpp, 0); - lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); - } - else - { - lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - } + create_list_item_button(format_list_name(s_record_names[index]), + static_cast(index) + kListUserDataOffset, + static_cast(index) == state.selected_record_idx, + false); } } + append_back_list_item(); - const bool show_pager = total > static_cast(kListPageSize); - if (state.list_prev_btn) + lv_obj_add_flag(state.list_container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(state.list_container, LV_DIR_VER); + lv_obj_set_scrollbar_mode(state.list_container, LV_SCROLLBAR_MODE_AUTO); + lv_obj_scroll_to_y(state.list_container, scroll_y, LV_ANIM_OFF); + if (state.focus_col == TrackerPageState::FocusColumn::Main && !is_any_modal_open()) { - if (show_pager) - { - lv_obj_clear_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - if (state.record_page > 0) - { - lv_obj_clear_state(state.list_prev_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.list_prev_btn, LV_STATE_DISABLED); - } - } - else - { - lv_obj_add_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - } + tracker::ui::input::tracker_input_on_ui_refreshed(); } - if (state.list_next_btn) - { - if (show_pager) - { - lv_obj_clear_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); - if (state.record_page < max_page) - { - lv_obj_clear_state(state.list_next_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.list_next_btn, LV_STATE_DISABLED); - } - } - else - { - lv_obj_add_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); - } - } - update_del_button(); } void refresh_record_list() @@ -816,19 +671,15 @@ void refresh_record_list() return; } s_record_names.clear(); + s_record_empty_text = "No tracks yet"; if (SD.cardType() == CARD_NONE) { - set_placeholder_row(state.list_item_btns[0], state.list_item_labels[0], "No SD Card"); - for (int i = 1; i < kListPageSize; ++i) + s_record_empty_text = "No SD Card"; + if (state.mode == TrackerPageState::Mode::Record) { - if (state.list_item_btns[i]) - { - lv_obj_add_flag(state.list_item_btns[i], LV_OBJ_FLAG_HIDDEN); - } + update_record_page(); } - if (state.list_prev_btn) lv_obj_add_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - if (state.list_next_btn) lv_obj_add_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); return; } @@ -874,145 +725,43 @@ void update_route_status() lv_label_set_text(state.status_label, "No route selected"); } } - if (state.load_btn) - { - const bool active = !state.active_route.empty(); - if (active) - { - lv_obj_add_flag(state.load_btn, LV_OBJ_FLAG_HIDDEN); - } - else - { - lv_obj_clear_flag(state.load_btn, LV_OBJ_FLAG_HIDDEN); - const bool can_load = (state.selected_route_idx >= 0) && !state.selected_route.empty(); - if (can_load) - { - lv_obj_clear_state(state.load_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.load_btn, LV_STATE_DISABLED); - } - } - } - if (state.unload_btn) - { - const bool active = !state.active_route.empty(); - if (active) - { - lv_obj_clear_flag(state.unload_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_state(state.unload_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_flag(state.unload_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_state(state.unload_btn, LV_STATE_DISABLED); - } - } - update_del_button(); } void update_route_page() { auto& state = g_tracker_state; - const size_t total = s_route_names.size(); - int max_page = 0; - if (total > 0) + if (!state.list_container) { - max_page = static_cast((total - 1) / kListPageSize); + return; } - if (state.route_page < 0) state.route_page = 0; - if (state.route_page > max_page) state.route_page = max_page; - const int start = state.route_page * kListPageSize; - if (total == 0) + const lv_coord_t scroll_y = lv_obj_get_scroll_y(state.list_container); + clear_list_items(); + + if (s_route_names.empty()) { - set_placeholder_row(state.list_item_btns[0], state.list_item_labels[0], "No KML routes"); - for (int i = 1; i < kListPageSize; ++i) - { - if (state.list_item_btns[i]) - { - lv_obj_add_flag(state.list_item_btns[i], LV_OBJ_FLAG_HIDDEN); - } - } + create_list_item_button(s_route_empty_text.c_str(), 0, false, true); } else { - for (int i = 0; i < kListPageSize; ++i) + for (size_t index = 0; index < s_route_names.size(); ++index) { - lv_obj_t* btn = state.list_item_btns[i]; - lv_obj_t* label = state.list_item_labels[i]; - if (!btn || !label) - { - continue; - } - const int idx = start + i; - if (idx < static_cast(total)) - { - String display_name = format_list_name(s_route_names[idx]); - lv_label_set_text(label, display_name.c_str()); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_state(btn, LV_STATE_DISABLED); - if (idx == state.selected_route_idx) - { - lv_obj_add_state(btn, LV_STATE_CHECKED); - } - else - { - lv_obj_clear_state(btn, LV_STATE_CHECKED); - } - lv_obj_set_user_data(btn, - reinterpret_cast((uintptr_t)idx + kListUserDataOffset)); - lv_obj_set_style_text_font(label, &lv_font_noto_cjk_16_2bpp, 0); - lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); - } - else - { - lv_obj_add_flag(btn, LV_OBJ_FLAG_HIDDEN); - } + create_list_item_button(format_list_name(s_route_names[index]), + static_cast(index) + kListUserDataOffset, + static_cast(index) == state.selected_route_idx, + false); } } + append_back_list_item(); - const bool show_pager = total > static_cast(kListPageSize); - if (state.list_prev_btn) + lv_obj_add_flag(state.list_container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(state.list_container, LV_DIR_VER); + lv_obj_set_scrollbar_mode(state.list_container, LV_SCROLLBAR_MODE_AUTO); + lv_obj_scroll_to_y(state.list_container, scroll_y, LV_ANIM_OFF); + if (state.focus_col == TrackerPageState::FocusColumn::Main && !is_any_modal_open()) { - if (show_pager) - { - lv_obj_clear_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - if (state.route_page > 0) - { - lv_obj_clear_state(state.list_prev_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.list_prev_btn, LV_STATE_DISABLED); - } - } - else - { - lv_obj_add_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - } + tracker::ui::input::tracker_input_on_ui_refreshed(); } - if (state.list_next_btn) - { - if (show_pager) - { - lv_obj_clear_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); - if (state.route_page < max_page) - { - lv_obj_clear_state(state.list_next_btn, LV_STATE_DISABLED); - } - else - { - lv_obj_add_state(state.list_next_btn, LV_STATE_DISABLED); - } - } - else - { - lv_obj_add_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); - } - } - update_del_button(); } void refresh_route_list() @@ -1025,35 +774,26 @@ void refresh_route_list() s_route_names.clear(); state.selected_route_idx = -1; state.selected_route.clear(); + s_route_empty_text = "No KML routes"; if (SD.cardType() == CARD_NONE) { - set_placeholder_row(state.list_item_btns[0], state.list_item_labels[0], "No SD Card"); - for (int i = 1; i < kListPageSize; ++i) + s_route_empty_text = "No SD Card"; + if (state.mode == TrackerPageState::Mode::Route) { - if (state.list_item_btns[i]) - { - lv_obj_add_flag(state.list_item_btns[i], LV_OBJ_FLAG_HIDDEN); - } + update_route_page(); } - if (state.list_prev_btn) lv_obj_add_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - if (state.list_next_btn) lv_obj_add_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); return; } File dir = SD.open(kRouteDir); if (!dir || !dir.isDirectory()) { - set_placeholder_row(state.list_item_btns[0], state.list_item_labels[0], "No routes folder"); - for (int i = 1; i < kListPageSize; ++i) + s_route_empty_text = "No routes folder"; + if (state.mode == TrackerPageState::Mode::Route) { - if (state.list_item_btns[i]) - { - lv_obj_add_flag(state.list_item_btns[i], LV_OBJ_FLAG_HIDDEN); - } + update_route_page(); } - if (state.list_prev_btn) lv_obj_add_flag(state.list_prev_btn, LV_OBJ_FLAG_HIDDEN); - if (state.list_next_btn) lv_obj_add_flag(state.list_next_btn, LV_OBJ_FLAG_HIDDEN); return; } @@ -1140,62 +880,6 @@ void on_mode_route_clicked(lv_event_t*) focus_main_panel(); } -void on_list_prev_clicked(lv_event_t*) -{ - auto& state = g_tracker_state; - if (state.mode == TrackerPageState::Mode::Record) - { - if (state.record_page > 0) - { - state.record_page--; - update_record_page(); - } - } - else - { - if (state.route_page > 0) - { - state.route_page--; - update_route_page(); - } - } - focus_main_panel(); -} - -void on_list_next_clicked(lv_event_t*) -{ - auto& state = g_tracker_state; - if (state.mode == TrackerPageState::Mode::Record) - { - state.record_page++; - update_record_page(); - } - else - { - state.route_page++; - update_route_page(); - } - focus_main_panel(); -} - -void on_list_back_clicked(lv_event_t*) -{ - focus_mode_panel(); -} - -void on_list_back_key(lv_event_t* e) -{ - if (!e) - { - return; - } - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ENTER) - { - focus_mode_panel(); - } -} - void on_list_item_clicked(lv_event_t* e) { if (!e) @@ -1209,12 +893,30 @@ void on_list_item_clicked(lv_event_t* e) { return; } - uintptr_t raw = reinterpret_cast(ud); - if (raw < kListUserDataOffset) + const intptr_t raw = reinterpret_cast(ud); + if (raw == kBackListItemUserData) + { + if (state.mode == TrackerPageState::Mode::Record) + { + state.selected_record_idx = -1; + state.selected_record.clear(); + update_record_status(); + } + else + { + state.selected_route_idx = -1; + state.selected_route.clear(); + update_route_status(); + } + sync_list_item_checked_states(); + focus_mode_panel(); + return; + } + if (raw < static_cast(kListUserDataOffset)) { return; } - uintptr_t idx = raw - kListUserDataOffset; + const size_t idx = static_cast(raw - static_cast(kListUserDataOffset)); if (state.mode == TrackerPageState::Mode::Record) { @@ -1224,7 +926,6 @@ void on_list_item_clicked(lv_event_t* e) } state.selected_record_idx = static_cast(idx); state.selected_record = s_record_names[idx].c_str(); - update_record_page(); update_record_status(); } else @@ -1236,15 +937,180 @@ void on_list_item_clicked(lv_event_t* e) state.selected_route_idx = static_cast(idx); state.selected_route = s_route_names[idx].c_str(); update_route_status(); - update_route_page(); } - update_del_button(); - focus_action_panel(); + sync_list_item_checked_states(); + open_action_menu_modal(); } -void on_action_back_clicked(lv_event_t*) +lv_obj_t* create_action_menu_button(lv_obj_t* parent, const char* text) { - focus_main_panel(); + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_size(btn, LV_PCT(100), kSecondaryButtonHeight); + + lv_obj_t* label = lv_label_create(btn); + lv_label_set_text(label, text); + lv_obj_center(label); + apply_action_button(btn, label); + return btn; +} + +void on_action_menu_key(lv_event_t* e) +{ + if (!e) + { + return; + } + const uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + modal_close(g_tracker_state.action_menu_modal); + focus_main_panel(); + } +} + +void on_action_menu_item_clicked(lv_event_t* e) +{ + if (!e) + { + return; + } + const auto cmd = static_cast( + reinterpret_cast(lv_event_get_user_data(e))); + + modal_close(g_tracker_state.action_menu_modal); + + switch (cmd) + { + case ActionMenuCommand::Load: + on_route_load_clicked(nullptr); + focus_main_panel(); + break; + case ActionMenuCommand::Unload: + on_route_unload_clicked(nullptr); + focus_main_panel(); + break; + case ActionMenuCommand::Delete: + open_delete_confirm_modal(); + break; + case ActionMenuCommand::Cancel: + default: + focus_main_panel(); + break; + } +} + +void open_action_menu_modal() +{ + auto& state = g_tracker_state; + if (is_any_modal_open()) + { + return; + } + + bool has_item = false; + int action_count = 1; // Cancel + if (state.mode == TrackerPageState::Mode::Record) + { + has_item = state.selected_record_idx >= 0 && + state.selected_record_idx < static_cast(s_record_names.size()); + if (can_delete_selected_item()) + { + ++action_count; + } + } + else + { + has_item = state.selected_route_idx >= 0 && + state.selected_route_idx < static_cast(s_route_names.size()); + if (can_load_selected_route()) + { + ++action_count; + } + if (can_unload_active_route()) + { + ++action_count; + } + if (can_delete_selected_item()) + { + ++action_count; + } + } + + if (!has_item) + { + return; + } + + modal_prepare_group(); + const int modal_h = std::min(220, 56 + action_count * (kSecondaryButtonHeight + 4)); + state.action_menu_modal = create_modal_root(190, modal_h); + lv_obj_t* win = lv_obj_get_child(state.action_menu_modal, 0); + if (!win) + { + modal_close(state.action_menu_modal); + return; + } + + lv_obj_set_flex_flow(win, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(win, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(win, 4, LV_PART_MAIN); + + lv_obj_t* title_label = lv_label_create(win); + const char* title = (state.mode == TrackerPageState::Mode::Record) ? "Track Actions" : "Route Actions"; + lv_label_set_text(title_label, title); + lv_obj_set_width(title_label, LV_PCT(100)); + lv_label_set_long_mode(title_label, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(title_label, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t* list = lv_obj_create(win); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_height(list, 0); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(list, 2, LV_PART_MAIN); + lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_AUTO); + + lv_obj_t* first_focus = nullptr; + auto add_action = [&](ActionMenuCommand cmd, const char* text) + { + lv_obj_t* btn = create_action_menu_button(list, text); + lv_obj_add_event_cb(btn, + on_action_menu_item_clicked, + LV_EVENT_CLICKED, + reinterpret_cast(static_cast(cmd))); + lv_obj_add_event_cb(btn, on_action_menu_key, LV_EVENT_KEY, nullptr); + lv_group_add_obj(state.modal_group, btn); + if (!first_focus) + { + first_focus = btn; + } + }; + + if (state.mode == TrackerPageState::Mode::Route) + { + if (can_load_selected_route()) + { + add_action(ActionMenuCommand::Load, "Load"); + } + if (can_unload_active_route()) + { + add_action(ActionMenuCommand::Unload, "Off"); + } + } + if (can_delete_selected_item()) + { + add_action(ActionMenuCommand::Delete, "Delete"); + } + add_action(ActionMenuCommand::Cancel, "Cancel"); + + if (first_focus) + { + lv_group_focus_obj(first_focus); + } } void on_list_item_focused(lv_event_t* e) @@ -1259,12 +1125,22 @@ void on_list_item_focused(lv_event_t* e) { return; } - uintptr_t raw = reinterpret_cast(ud); - if (raw < kListUserDataOffset) + lv_obj_scroll_to_view(target, LV_ANIM_OFF); + const intptr_t raw = reinterpret_cast(ud); + if (raw == kBackListItemUserData) + { + if (lv_obj_t* label = lv_obj_get_child(target, -1)) + { + lv_label_set_text(label, "Back"); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + } + return; + } + if (raw < static_cast(kListUserDataOffset)) { return; } - const size_t idx = static_cast(raw - kListUserDataOffset); + const size_t idx = static_cast(raw - static_cast(kListUserDataOffset)); const auto& names = (g_tracker_state.mode == TrackerPageState::Mode::Record) ? s_record_names : s_route_names; if (idx >= names.size()) @@ -1293,12 +1169,21 @@ void on_list_item_defocused(lv_event_t* e) { return; } - uintptr_t raw = reinterpret_cast(ud); - if (raw < kListUserDataOffset) + const intptr_t raw = reinterpret_cast(ud); + if (raw == kBackListItemUserData) + { + if (lv_obj_t* label = lv_obj_get_child(target, -1)) + { + lv_label_set_text(label, "Back"); + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + } + return; + } + if (raw < static_cast(kListUserDataOffset)) { return; } - const size_t idx = static_cast(raw - kListUserDataOffset); + const size_t idx = static_cast(raw - static_cast(kListUserDataOffset)); const auto& names = (g_tracker_state.mode == TrackerPageState::Mode::Record) ? s_record_names : s_route_names; if (idx >= names.size()) @@ -1312,6 +1197,7 @@ void on_list_item_defocused(lv_event_t* e) } String display_name = format_list_name(names[idx]); lv_label_set_text(label, display_name.c_str()); + lv_obj_add_style(label, &s_btn_label, LV_PART_MAIN); lv_obj_set_style_text_font(label, &lv_font_noto_cjk_16_2bpp, 0); lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); } @@ -1466,7 +1352,6 @@ void on_del_confirm_clicked(lv_event_t*) { lv_label_set_text(state.status_label, "Delete failed"); } - update_del_button(); modal_close(state.del_confirm_modal); } @@ -1495,7 +1380,6 @@ void open_delete_confirm_modal() return; } state.pending_delete_mode = TrackerPageState::Mode::Record; - state.pending_delete_idx = state.selected_record_idx; state.pending_delete_name = s_record_names[state.selected_record_idx].c_str(); String path = String(gps::TrackRecorder::kTrackDir) + "/" + s_record_names[state.selected_record_idx]; @@ -1513,7 +1397,6 @@ void open_delete_confirm_modal() return; } state.pending_delete_mode = TrackerPageState::Mode::Route; - state.pending_delete_idx = state.selected_route_idx; state.pending_delete_name = s_route_names[state.selected_route_idx].c_str(); String path = String(kRouteDir) + "/" + s_route_names[state.selected_route_idx]; state.pending_delete_path = path.c_str(); @@ -1561,10 +1444,6 @@ void open_delete_confirm_modal() lv_group_focus_obj(cancel_btn); } -void on_del_clicked(lv_event_t*) -{ - open_delete_confirm_modal(); -} } // namespace void refresh_page() @@ -1576,8 +1455,7 @@ void refresh_page() refresh_record_list(); refresh_route_list(); update_route_status(); - update_del_button(); - refresh_focus_group(); + tracker::ui::input::tracker_input_on_ui_refreshed(); ui_update_top_bar_battery(g_tracker_state.top_bar); } @@ -1599,7 +1477,6 @@ void init_page(lv_obj_t* parent) state.content = layout::create_content(state.root); state.filter_panel = layout::create_filter_panel(state.content, kFilterPanelWidth); state.list_panel = layout::create_list_panel(state.content); - state.action_panel = layout::create_action_panel(state.content, kActionPanelWidth); state.mode_record_btn = lv_btn_create(state.filter_panel); lv_obj_set_width(state.mode_record_btn, LV_PCT(100)); @@ -1619,47 +1496,14 @@ void init_page(lv_obj_t* parent) lv_label_set_text(state.status_label, "Stopped"); lv_obj_set_width(state.status_label, LV_PCT(100)); lv_obj_set_style_text_font(state.status_label, &lv_font_noto_cjk_16_2bpp, 0); + lv_obj_set_style_text_color(state.status_label, lv_color_hex(kPanelTextMuted), 0); state.list_container = layout::create_list_container(state.list_panel); - for (int i = 0; i < kListPageSize; ++i) - { - lv_obj_t* btn = lv_btn_create(state.list_container); - lv_obj_set_size(btn, LV_PCT(100), kListItemHeight); - lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_t* label = lv_label_create(btn); - lv_obj_align(label, LV_ALIGN_LEFT_MID, 10, 0); - lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); - lv_obj_set_width(label, LV_PCT(100)); - apply_list_button(btn); - state.list_item_btns[i] = btn; - state.list_item_labels[i] = label; - lv_obj_add_event_cb(btn, on_list_item_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(btn, on_list_item_focused, LV_EVENT_FOCUSED, nullptr); - lv_obj_add_event_cb(btn, on_list_item_defocused, LV_EVENT_DEFOCUSED, nullptr); - } + lv_obj_add_flag(state.list_container, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(state.list_container, LV_DIR_VER); + lv_obj_set_scrollbar_mode(state.list_container, LV_SCROLLBAR_MODE_AUTO); state.bottom_bar = layout::create_bottom_bar(state.list_panel); - state.list_prev_btn = lv_btn_create(state.bottom_bar); - lv_obj_set_size(state.list_prev_btn, kBottomBarButtonWidth, kListItemHeight); - state.list_prev_label = lv_label_create(state.list_prev_btn); - lv_label_set_text(state.list_prev_label, "Prev"); - lv_obj_center(state.list_prev_label); - apply_action_button(state.list_prev_btn, state.list_prev_label); - - state.list_next_btn = lv_btn_create(state.bottom_bar); - lv_obj_set_size(state.list_next_btn, kBottomBarButtonWidth, kListItemHeight); - state.list_next_label = lv_label_create(state.list_next_btn); - lv_label_set_text(state.list_next_label, "Next"); - lv_obj_center(state.list_next_label); - apply_action_button(state.list_next_btn, state.list_next_label); - - state.list_back_btn = lv_btn_create(state.bottom_bar); - lv_obj_set_size(state.list_back_btn, kBottomBarButtonWidth, kListItemHeight); - state.list_back_label = lv_label_create(state.list_back_btn); - lv_label_set_text(state.list_back_label, "Back"); - lv_obj_center(state.list_back_label); - apply_action_button(state.list_back_btn, state.list_back_label); - state.start_stop_btn = lv_btn_create(state.bottom_bar); lv_obj_set_size(state.start_stop_btn, kBottomBarButtonWidth, kPrimaryButtonHeight); state.start_stop_label = lv_label_create(state.start_stop_btn); @@ -1668,42 +1512,6 @@ void init_page(lv_obj_t* parent) lv_obj_center(state.start_stop_label); apply_action_button(state.start_stop_btn, state.start_stop_label); - state.load_btn = lv_btn_create(state.action_panel); - lv_obj_set_size(state.load_btn, LV_PCT(100), kSecondaryButtonHeight); - state.load_label = lv_label_create(state.load_btn); - lv_label_set_text(state.load_label, "Load"); - lv_obj_set_width(state.load_label, LV_PCT(100)); - lv_label_set_long_mode(state.load_label, LV_LABEL_LONG_DOT); - lv_obj_center(state.load_label); - apply_action_button(state.load_btn, state.load_label); - - state.unload_btn = lv_btn_create(state.action_panel); - lv_obj_set_size(state.unload_btn, LV_PCT(100), kSecondaryButtonHeight); - state.unload_label = lv_label_create(state.unload_btn); - lv_label_set_text(state.unload_label, "Off"); - lv_obj_set_width(state.unload_label, LV_PCT(100)); - lv_label_set_long_mode(state.unload_label, LV_LABEL_LONG_DOT); - lv_obj_center(state.unload_label); - apply_action_button(state.unload_btn, state.unload_label); - - state.del_btn = lv_btn_create(state.action_panel); - lv_obj_set_size(state.del_btn, LV_PCT(100), kSecondaryButtonHeight); - state.del_label = lv_label_create(state.del_btn); - lv_label_set_text(state.del_label, "Del"); - lv_obj_set_width(state.del_label, LV_PCT(100)); - lv_label_set_long_mode(state.del_label, LV_LABEL_LONG_DOT); - lv_obj_center(state.del_label); - apply_action_button(state.del_btn, state.del_label); - - state.action_back_btn = lv_btn_create(state.action_panel); - lv_obj_set_size(state.action_back_btn, LV_PCT(100), kSecondaryButtonHeight); - state.action_back_label = lv_label_create(state.action_back_btn); - lv_label_set_text(state.action_back_label, "Back"); - lv_obj_set_width(state.action_back_label, LV_PCT(100)); - lv_label_set_long_mode(state.action_back_label, LV_LABEL_LONG_DOT); - lv_obj_center(state.action_back_label); - apply_action_button(state.action_back_btn, state.action_back_label); - ::ui::widgets::TopBarConfig cfg; cfg.height = ::ui::widgets::kTopBarHeight; ::ui::widgets::top_bar_init(state.top_bar, state.header, cfg); @@ -1716,25 +1524,20 @@ void init_page(lv_obj_t* parent) lv_obj_add_event_cb(state.mode_route_btn, on_mode_route_focused, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(state.mode_record_btn, on_mode_record_key, LV_EVENT_KEY, nullptr); lv_obj_add_event_cb(state.mode_route_btn, on_mode_route_key, LV_EVENT_KEY, nullptr); - lv_obj_add_event_cb(state.list_back_btn, on_list_back_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.list_back_btn, on_list_back_key, LV_EVENT_KEY, nullptr); - lv_obj_add_event_cb(state.list_prev_btn, on_list_prev_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.list_next_btn, on_list_next_clicked, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(state.start_stop_btn, on_start_stop_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.load_btn, on_route_load_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.unload_btn, on_route_unload_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.del_btn, on_del_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(state.action_back_btn, on_action_back_clicked, LV_EVENT_CLICKED, nullptr); set_mode(TrackerPageState::Mode::Record); refresh_page(); - focus_mode_panel(); - state.initialized = true; } void cleanup_page() { auto& state = g_tracker_state; + if (state.action_menu_modal) + { + lv_obj_del(state.action_menu_modal); + state.action_menu_modal = nullptr; + } if (state.del_confirm_modal) { lv_obj_del(state.del_confirm_modal); diff --git a/src/ui/screens/tracker/tracker_page_input.cpp b/src/ui/screens/tracker/tracker_page_input.cpp index e7e87e99..d5b21c3b 100644 --- a/src/ui/screens/tracker/tracker_page_input.cpp +++ b/src/ui/screens/tracker/tracker_page_input.cpp @@ -1,7 +1,233 @@ #include "tracker_page_input.h" +#include "../../components/two_pane_nav.h" #include "tracker_state.h" +namespace +{ + +using Adapter = ::ui::components::two_pane_nav::Adapter; +using BackPlacement = ::ui::components::two_pane_nav::BackPlacement; +using NavFocusColumn = ::ui::components::two_pane_nav::FocusColumn; + +static ::ui::components::two_pane_nav::Binding s_binding{}; +static constexpr intptr_t kBackListItemUserData = -2; +static constexpr intptr_t kListUserDataOffset = 1; + +static bool is_alive(void* /*ctx*/) +{ + auto& state = tracker::ui::g_tracker_state; + return state.root && lv_obj_is_valid(state.root); +} + +static lv_obj_t* get_key_target(void* /*ctx*/) +{ + auto& state = tracker::ui::g_tracker_state; + return state.root ? state.root : state.list_panel; +} + +static lv_obj_t* get_top_back_button(void* /*ctx*/) +{ + return tracker::ui::g_tracker_state.top_bar.back_btn; +} + +static size_t get_filter_count(void* /*ctx*/) +{ + return 2; +} + +static lv_obj_t* get_filter_button(void* /*ctx*/, size_t index) +{ + auto& state = tracker::ui::g_tracker_state; + switch (index) + { + case 0: + return state.mode_record_btn; + case 1: + return state.mode_route_btn; + default: + return nullptr; + } +} + +static int get_preferred_filter_index(void* /*ctx*/) +{ + return tracker::ui::g_tracker_state.mode == tracker::ui::TrackerPageState::Mode::Record ? 0 : 1; +} + +static bool is_focusable_list_child(lv_obj_t* obj) +{ + if (!obj || !lv_obj_is_valid(obj)) + { + return false; + } + if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) || lv_obj_has_state(obj, LV_STATE_DISABLED)) + { + return false; + } + const intptr_t raw = reinterpret_cast(lv_obj_get_user_data(obj)); + return raw != kBackListItemUserData; +} + +static lv_obj_t* get_list_back_button(void* /*ctx*/) +{ + auto& state = tracker::ui::g_tracker_state; + if (!state.list_container) + { + return nullptr; + } + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t index = 0; index < child_count; ++index) + { + lv_obj_t* child = lv_obj_get_child(state.list_container, index); + if (!child || !lv_obj_is_valid(child)) + { + continue; + } + const intptr_t raw = reinterpret_cast(lv_obj_get_user_data(child)); + if (raw == kBackListItemUserData) + { + return child; + } + } + return nullptr; +} + +static bool include_start_stop_button() +{ + auto& state = tracker::ui::g_tracker_state; + if (state.mode != tracker::ui::TrackerPageState::Mode::Record) + { + return false; + } + if (!state.start_stop_btn || !lv_obj_is_valid(state.start_stop_btn)) + { + return false; + } + if (lv_obj_has_flag(state.start_stop_btn, LV_OBJ_FLAG_HIDDEN) || + lv_obj_has_state(state.start_stop_btn, LV_STATE_DISABLED)) + { + return false; + } + return true; +} + +static size_t get_list_count(void* /*ctx*/) +{ + auto& state = tracker::ui::g_tracker_state; + size_t count = 0; + if (state.list_container) + { + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t index = 0; index < child_count; ++index) + { + if (is_focusable_list_child(lv_obj_get_child(state.list_container, index))) + { + ++count; + } + } + } + if (include_start_stop_button()) + { + ++count; + } + return count; +} + +static lv_obj_t* get_list_button(void* /*ctx*/, size_t index) +{ + auto& state = tracker::ui::g_tracker_state; + size_t current = 0; + if (state.list_container) + { + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t child_index = 0; child_index < child_count; ++child_index) + { + lv_obj_t* child = lv_obj_get_child(state.list_container, child_index); + if (!is_focusable_list_child(child)) + { + continue; + } + if (current == index) + { + return child; + } + ++current; + } + } + if (include_start_stop_button() && current == index) + { + return state.start_stop_btn; + } + return nullptr; +} + +static int get_preferred_list_index(void* /*ctx*/) +{ + auto& state = tracker::ui::g_tracker_state; + if (state.mode == tracker::ui::TrackerPageState::Mode::Record) + { + return state.selected_record_idx >= 0 ? state.selected_record_idx : -1; + } + return state.selected_route_idx >= 0 ? state.selected_route_idx : -1; +} + +static bool handle_list_enter(void* /*ctx*/, lv_obj_t* focused) +{ + auto& state = tracker::ui::g_tracker_state; + if (!focused || !lv_obj_is_valid(focused)) + { + return false; + } + if (focused == state.start_stop_btn) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return true; + } + if (state.list_container) + { + const uint32_t child_count = lv_obj_get_child_count(state.list_container); + for (uint32_t index = 0; index < child_count; ++index) + { + if (focused == lv_obj_get_child(state.list_container, index)) + { + lv_obj_send_event(focused, LV_EVENT_CLICKED, nullptr); + return true; + } + } + } + return false; +} + +static void on_column_changed(void* /*ctx*/, NavFocusColumn column) +{ + tracker::ui::g_tracker_state.focus_col = + (column == NavFocusColumn::Filter) + ? tracker::ui::TrackerPageState::FocusColumn::Mode + : tracker::ui::TrackerPageState::FocusColumn::Main; +} + +static Adapter make_adapter() +{ + Adapter adapter{}; + adapter.is_alive = is_alive; + adapter.get_key_target = get_key_target; + adapter.get_top_back_button = get_top_back_button; + adapter.get_filter_count = get_filter_count; + adapter.get_filter_button = get_filter_button; + adapter.get_preferred_filter_index = get_preferred_filter_index; + adapter.get_list_count = get_list_count; + adapter.get_list_button = get_list_button; + adapter.get_preferred_list_index = get_preferred_list_index; + adapter.get_list_back_button = get_list_back_button; + adapter.handle_list_enter = handle_list_enter; + adapter.on_column_changed = on_column_changed; + adapter.filter_top_back_placement = BackPlacement::Trailing; + return adapter; +} + +} // namespace + namespace tracker { namespace ui @@ -9,25 +235,34 @@ namespace ui namespace input { -void bind_focus(lv_group_t* group) +void init_tracker_input() { - if (!group) - { - return; - } - auto& state = g_tracker_state; - if (state.top_bar.back_btn) - { - lv_group_add_obj(group, state.top_bar.back_btn); - } - if (state.mode_record_btn) - { - lv_group_add_obj(group, state.mode_record_btn); - } - if (state.mode_route_btn) - { - lv_group_add_obj(group, state.mode_route_btn); - } + ::ui::components::two_pane_nav::init(&s_binding, make_adapter()); +} + +void cleanup_tracker_input() +{ + ::ui::components::two_pane_nav::cleanup(&s_binding); +} + +void tracker_input_on_ui_refreshed() +{ + ::ui::components::two_pane_nav::on_ui_refreshed(&s_binding); +} + +void tracker_focus_to_filter() +{ + ::ui::components::two_pane_nav::focus_filter(&s_binding); +} + +void tracker_focus_to_list() +{ + ::ui::components::two_pane_nav::focus_list(&s_binding); +} + +lv_group_t* tracker_input_get_group() +{ + return ::ui::components::two_pane_nav::get_group(&s_binding); } } // namespace input diff --git a/src/ui/screens/tracker/tracker_page_input.h b/src/ui/screens/tracker/tracker_page_input.h index b22606fa..d543898c 100644 --- a/src/ui/screens/tracker/tracker_page_input.h +++ b/src/ui/screens/tracker/tracker_page_input.h @@ -9,7 +9,12 @@ namespace ui namespace input { -void bind_focus(lv_group_t* group); +void init_tracker_input(); +void cleanup_tracker_input(); +void tracker_input_on_ui_refreshed(); +void tracker_focus_to_filter(); +void tracker_focus_to_list(); +lv_group_t* tracker_input_get_group(); } // namespace input } // namespace ui diff --git a/src/ui/screens/tracker/tracker_page_layout.cpp b/src/ui/screens/tracker/tracker_page_layout.cpp index ee019aed..6a991e18 100644 --- a/src/ui/screens/tracker/tracker_page_layout.cpp +++ b/src/ui/screens/tracker/tracker_page_layout.cpp @@ -1,7 +1,7 @@ /** * Tracker layout aligned with contacts_page: * Root(COL) -> Header + Content(ROW) - * Content -> Filter Panel | List Panel | Action Panel + * Content -> Filter Panel | List Panel * List Panel -> Status Label + List Container + Bottom Bar */ @@ -52,7 +52,7 @@ lv_obj_t* create_header(lv_obj_t* root) { lv_obj_t* header = lv_obj_create(root); lv_obj_set_size(header, LV_PCT(100), ::ui::widgets::kTopBarHeight); - lv_obj_set_style_bg_color(header, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_bg_color(header, lv_color_hex(0xF6E6C6), 0); lv_obj_set_style_pad_all(header, 0, 0); apply_base_container_style(header); return header; @@ -85,7 +85,7 @@ lv_obj_t* create_filter_panel(lv_obj_t* content, int width) lv_obj_set_style_pad_row(panel, 3, LV_PART_MAIN); lv_obj_set_style_margin_left(panel, 0, LV_PART_MAIN); lv_obj_set_style_margin_right(panel, kPanelGap, LV_PART_MAIN); - lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(0xF6E6C6), 0); apply_base_container_style(panel); return panel; } @@ -101,22 +101,7 @@ lv_obj_t* create_list_panel(lv_obj_t* content) lv_obj_set_style_pad_row(panel, 2, LV_PART_MAIN); lv_obj_set_style_margin_left(panel, 0, LV_PART_MAIN); lv_obj_set_style_margin_right(panel, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFF7E9), 0); - apply_base_container_style(panel); - return panel; -} - -lv_obj_t* create_action_panel(lv_obj_t* content, int width) -{ - lv_obj_t* panel = lv_obj_create(content); - lv_obj_set_width(panel, width); - lv_obj_set_height(panel, LV_PCT(100)); - lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(panel, 3, LV_PART_MAIN); - lv_obj_set_style_pad_row(panel, 3, LV_PART_MAIN); - lv_obj_set_style_margin_left(panel, kPanelGap, LV_PART_MAIN); - lv_obj_set_style_margin_right(panel, 0, LV_PART_MAIN); - lv_obj_set_style_bg_color(panel, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(0xFAF0D8), 0); apply_base_container_style(panel); return panel; } @@ -130,7 +115,7 @@ lv_obj_t* create_list_container(lv_obj_t* list_panel) lv_obj_set_flex_flow(container, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(container, 2, LV_PART_MAIN); lv_obj_set_style_pad_all(container, 3, LV_PART_MAIN); - lv_obj_set_style_bg_color(container, lv_color_hex(0xFFF7E9), 0); + lv_obj_set_style_bg_color(container, lv_color_hex(0xFAF0D8), 0); apply_base_container_style(container); return container; } @@ -145,7 +130,7 @@ lv_obj_t* create_bottom_bar(lv_obj_t* list_panel) lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(bar, lv_color_hex(0xFFF7E9), 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(0xFAF0D8), 0); apply_base_container_style(bar); return bar; } diff --git a/src/ui/screens/tracker/tracker_page_layout.h b/src/ui/screens/tracker/tracker_page_layout.h index 6c31c679..ee3690bf 100644 --- a/src/ui/screens/tracker/tracker_page_layout.h +++ b/src/ui/screens/tracker/tracker_page_layout.h @@ -14,7 +14,6 @@ lv_obj_t* create_header(lv_obj_t* root); lv_obj_t* create_content(lv_obj_t* root); lv_obj_t* create_filter_panel(lv_obj_t* content, int width); lv_obj_t* create_list_panel(lv_obj_t* content); -lv_obj_t* create_action_panel(lv_obj_t* content, int width); lv_obj_t* create_list_container(lv_obj_t* list_panel); lv_obj_t* create_bottom_bar(lv_obj_t* list_panel); diff --git a/src/ui/screens/tracker/tracker_state.h b/src/ui/screens/tracker/tracker_state.h index 5b9432bb..8cef7125 100644 --- a/src/ui/screens/tracker/tracker_state.h +++ b/src/ui/screens/tracker/tracker_state.h @@ -2,7 +2,6 @@ #include "../../widgets/top_bar.h" #include "lvgl.h" -#include #include namespace tracker @@ -30,7 +29,6 @@ struct TrackerPageState lv_obj_t* list_panel = nullptr; lv_obj_t* list_container = nullptr; lv_obj_t* bottom_bar = nullptr; - lv_obj_t* action_panel = nullptr; lv_obj_t* mode_record_btn = nullptr; lv_obj_t* mode_record_label = nullptr; @@ -39,37 +37,17 @@ struct TrackerPageState lv_obj_t* status_label = nullptr; - std::array list_item_btns{}; - std::array list_item_labels{}; - - lv_obj_t* list_prev_btn = nullptr; - lv_obj_t* list_prev_label = nullptr; - lv_obj_t* list_next_btn = nullptr; - lv_obj_t* list_next_label = nullptr; - lv_obj_t* list_back_btn = nullptr; - lv_obj_t* list_back_label = nullptr; - lv_obj_t* start_stop_btn = nullptr; lv_obj_t* start_stop_label = nullptr; - lv_obj_t* load_btn = nullptr; - lv_obj_t* load_label = nullptr; - lv_obj_t* unload_btn = nullptr; - lv_obj_t* unload_label = nullptr; - lv_obj_t* del_btn = nullptr; - lv_obj_t* del_label = nullptr; - lv_obj_t* action_back_btn = nullptr; - lv_obj_t* action_back_label = nullptr; lv_obj_t* del_confirm_modal = nullptr; + lv_obj_t* action_menu_modal = nullptr; lv_group_t* modal_group = nullptr; lv_group_t* prev_group = nullptr; Mode pending_delete_mode = Mode::Record; - int pending_delete_idx = -1; std::string pending_delete_name{}; std::string pending_delete_path{}; - int record_page = 0; - int route_page = 0; ::ui::widgets::TopBar top_bar{}; Mode mode = Mode::Record; FocusColumn focus_col = FocusColumn::Mode; @@ -78,7 +56,6 @@ struct TrackerPageState int selected_route_idx = -1; std::string selected_route{}; std::string active_route{}; - bool initialized = false; }; extern TrackerPageState g_tracker_state; diff --git a/src/ui/ui_contacts.cpp b/src/ui/ui_contacts.cpp index 07c3b5cf..eef3d20c 100644 --- a/src/ui/ui_contacts.cpp +++ b/src/ui/ui_contacts.cpp @@ -78,7 +78,7 @@ void ui_contacts_enter(lv_obj_t* parent) // All layout code lives in the dedicated layout module. g_contacts_state.root = contacts::ui::layout::create_root(parent); - lv_obj_t* header = contacts::ui::layout::create_header( + contacts::ui::layout::create_header( g_contacts_state.root, contacts_top_bar_back, nullptr); @@ -89,17 +89,9 @@ void ui_contacts_enter(lv_obj_t* parent) // Initialize battery indicator on the shared top bar. ui_update_top_bar_battery(g_contacts_state.top_bar); - // Create only two columns: Filter + List (no persistent Action panel on the right). + // Build the two-pane page: filter column + list column. create_filter_panel(content); - create_list_panel(content); - g_contacts_state.action_panel = nullptr; - g_contacts_state.chat_btn = nullptr; - g_contacts_state.position_btn = nullptr; - g_contacts_state.edit_btn = nullptr; - g_contacts_state.del_btn = nullptr; - g_contacts_state.add_btn = nullptr; - g_contacts_state.info_btn = nullptr; - g_contacts_state.action_back_btn = nullptr; + contacts::ui::layout::create_list_panel(content); // Restore previous default group before initializing input. set_default_group(prev_group); diff --git a/src/ui/ui_tracker.cpp b/src/ui/ui_tracker.cpp index 1c5b88d4..7d9d8ebc 100644 --- a/src/ui/ui_tracker.cpp +++ b/src/ui/ui_tracker.cpp @@ -1,4 +1,5 @@ #include "screens/tracker/tracker_page_components.h" +#include "screens/tracker/tracker_page_input.h" #include "ui_common.h" void ui_tracker_enter(lv_obj_t* parent) @@ -6,19 +7,12 @@ void ui_tracker_enter(lv_obj_t* parent) lv_group_t* prev_group = lv_group_get_default(); set_default_group(nullptr); tracker::ui::components::init_page(parent); - extern lv_group_t* app_g; - if (app_g) - { - set_default_group(app_g); - lv_group_set_editing(app_g, false); - } - else - { - set_default_group(prev_group); - } + set_default_group(prev_group); + tracker::ui::input::init_tracker_input(); } void ui_tracker_exit(lv_obj_t*) { + tracker::ui::input::cleanup_tracker_input(); tracker::ui::components::cleanup_page(); } diff --git a/src/ui/ui_virtual_keys_tab5.cpp b/src/ui/ui_virtual_keys_tab5.cpp deleted file mode 100644 index f56ea15d..00000000 --- a/src/ui/ui_virtual_keys_tab5.cpp +++ /dev/null @@ -1,117 +0,0 @@ -#include "ui_virtual_keys_tab5.h" - -#if defined(ARDUINO_M5STACK_TAB5) - -#include "LV_Helper.h" - -namespace ui::tab5 -{ - -namespace -{ - -void send_key(lv_key_t key) -{ - lv_indev_t* indev = lv_get_keyboard_indev(); - if (!indev) - { - // If there is no keyboard indev, try encoder as a fallback. - indev = lv_get_encoder_indev(); - } - if (!indev) - { - return; - } - lv_group_t* group = lv_group_get_default(); - if (!group) - { - return; - } - - // Simulate a keyboard press+release sequence on the focused object. - lv_obj_t* focused = lv_group_get_focused(group); - if (!focused) - { - return; - } - - lv_event_send(focused, LV_EVENT_KEY, &key); -} - -void btn_event_back(lv_event_t* /*e*/) -{ - const lv_key_t key = LV_KEY_ESC; - send_key(key); -} - -void btn_event_up(lv_event_t* /*e*/) -{ - const lv_key_t key = LV_KEY_UP; - send_key(key); -} - -void btn_event_down(lv_event_t* /*e*/) -{ - const lv_key_t key = LV_KEY_DOWN; - send_key(key); -} - -void btn_event_ok(lv_event_t* /*e*/) -{ - const lv_key_t key = LV_KEY_ENTER; - send_key(key); -} - -} // namespace - -void init_virtual_keys(lv_obj_t* root) -{ - if (!root) - { - return; - } - - lv_obj_t* bar = lv_obj_create(root); - lv_obj_set_size(bar, LV_PCT(100), 40); - lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_obj_set_style_bg_opa(bar, LV_OPA_60, 0); - lv_obj_set_style_bg_color(bar, lv_color_hex(0x000000), 0); - lv_obj_set_style_border_width(bar, 0, 0); - lv_obj_set_style_pad_all(bar, 4, 0); - lv_obj_set_style_pad_column(bar, 6, 0); - lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - auto make_btn = [&](const char* text, lv_event_cb_t cb) - { - lv_obj_t* btn = lv_btn_create(bar); - lv_obj_set_size(btn, 60, LV_PCT(100)); - lv_obj_set_style_radius(btn, 8, 0); - lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(btn, lv_color_hex(0x222222), 0); - lv_obj_set_style_border_width(btn, 0, 0); - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); - - lv_obj_t* label = lv_label_create(btn); - lv_label_set_text(label, text); - lv_obj_set_style_text_color(label, lv_color_hex(0xFFFFFF), 0); - lv_obj_center(label); - }; - - make_btn("Back", btn_event_back); - make_btn("Up", btn_event_up); - make_btn("Down", btn_event_down); - make_btn("OK", btn_event_ok); -} - -} // namespace ui::tab5 - -// C-style shim for use from main.cpp without including this header there. -extern "C" void ui_tab5_init_virtual_keys(lv_obj_t* root) -{ - ui::tab5::init_virtual_keys(root); -} - -#endif // ARDUINO_M5STACK_TAB5 diff --git a/src/ui/ui_virtual_keys_tab5.h b/src/ui/ui_virtual_keys_tab5.h deleted file mode 100644 index 5b21fba4..00000000 --- a/src/ui/ui_virtual_keys_tab5.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#if defined(ARDUINO_M5STACK_TAB5) - -#include "lvgl.h" - -namespace ui::tab5 -{ - -// Create a bottom virtual navigation bar for Tab5 and attach it to the given root. -// This provides Back / Up / Down / OK keys for touch-only operation. -void init_virtual_keys(lv_obj_t* root); - -} // namespace ui::tab5 - -#endif // ARDUINO_M5STACK_TAB5 diff --git a/variants/lilygo_tlora_pager/envs/tlora_pager.ini b/variants/lilygo_tlora_pager/envs/tlora_pager.ini index a9d45ec1..36a5ad9f 100644 --- a/variants/lilygo_tlora_pager/envs/tlora_pager.ini +++ b/variants/lilygo_tlora_pager/envs/tlora_pager.ini @@ -3,7 +3,6 @@ extends = arduino_base board = lilygo-t-lora-pager build_src_filter = +<*> - - - - - @@ -31,7 +30,6 @@ extends = arduino_base board = lilygo-t-lora-pager build_src_filter = +<*> - - - - - @@ -62,7 +60,6 @@ extends = arduino_base board = lilygo-t-lora-pager build_src_filter = +<*> - - - - - @@ -89,7 +86,6 @@ extends = arduino_base board = lilygo-t-lora-pager build_src_filter = +<*> - - - - - diff --git a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini index 44f39ab2..6e8ea7f6 100644 --- a/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini +++ b/variants/lilygo_twatch_s3/envs/lilygo_twatch_s3.ini @@ -7,7 +7,6 @@ build_src_filter = +<*> - - - - + - build_flags = @@ -35,7 +34,6 @@ build_src_filter = +<*> - - - - + - build_flags = diff --git a/variants/m5stack_tab5/envs/m5stack_tab5.ini b/variants/m5stack_tab5/envs/m5stack_tab5.ini deleted file mode 100644 index 0b46a2de..00000000 --- a/variants/m5stack_tab5/envs/m5stack_tab5.ini +++ /dev/null @@ -1,31 +0,0 @@ -; M5Stack Tab5 (ESP32-P4) environment -; Uses pioarduino platform-espressif32 fork (develop branch) for ESP32-P4 support. - -[env:m5stack_tab5] -extends = idf_base - -; Custom board definition lives in boards/m5stack-tab5.json -board = m5stack-tab5 - -upload_speed = 115200 - -build_src_filter = - +<*> - - - - - - - + - -build_flags = - ${idf_base.build_flags} - -DBOARD_HAS_PSRAM=1 - -DARDUINO_M5STACK_TAB5 - -DDISPLAY_DRIVER_ST7703 ; Tab5 uses MIPI DSI + ST7703/ILI9881C family - -DSCREEN_WIDTH=1280 - -DSCREEN_HEIGHT=720 - -DUSING_INPUT_DEV_TOUCHPAD - -I variants/m5stack_tab5 - -lib_deps = - ${idf_base.lib_deps} - diff --git a/variants/tdeck/envs/tdeck.ini b/variants/tdeck/envs/tdeck.ini index 7a0ddb1f..57d70a0e 100644 --- a/variants/tdeck/envs/tdeck.ini +++ b/variants/tdeck/envs/tdeck.ini @@ -6,8 +6,6 @@ build_src_filter = +<*> - - - - - - - + build_flags =