diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 038c12bb..bb978f53 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1130,6 +1130,7 @@ static uint32_t _atoi(const char* sp) { #endif #if defined(MECK_TWATCH) #include "WatchNotesScreen.h" // After UITask.h -- needs NodePrefs + #include "WatchChannelConfigScreen.h" // After UITask.h -- needs NodePrefs, the_mesh #endif #if defined(MECK_TWATCH) && HAS_GPS #include "WatchMapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier @@ -1385,6 +1386,13 @@ static void lastHeardToggleContact() { if (wn) wn->handleTap(x, y); return 0; } + // Channel config screen: list rows, detail action rows and the footer are + // all tap targets, in the same logical coords as the alarm/notes screens. + if (ui_task.isOnWatchChannelConfigScreen()) { + WatchChannelConfigScreen* wc = (WatchChannelConfigScreen*)ui_task.getWatchChannelConfigScreen(); + if (wc) wc->handleTap(x, y); + return 0; + } #endif #if defined(MECK_TWATCH) // Watch: tiles open on long-press and pages change on swipe, so a plain tap diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 9d9156a3..283dd971 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -341,6 +341,7 @@ private: // T5S3: signal UITask to open VKB when entering text edit mode bool _needsTextVKB; + bool _wantsWatchChannels; // Watch: hand off to WatchChannelConfigScreen (polled by UITask) // 4G modem state (runtime cache of config) #ifdef HAS_4G_MODEM @@ -698,7 +699,7 @@ public: _editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0), _editFloat(0), _editInt(0), _fontPickerOriginal(0), _confirmAction(0), _onboarding(false), _subScreen(SUB_NONE), _savedTopCursor(0), - _radioChanged(false), _needsTextVKB(false) { + _radioChanged(false), _needsTextVKB(false), _wantsWatchChannels(false) { memset(_editBuf, 0, sizeof(_editBuf)); #ifdef HAS_SDCARD _savedExportCursor = 0; @@ -939,6 +940,8 @@ public: // T5S3 VKB integration for text editing (channel name, device name, freq, APN) bool needsTextVKB() const { return _needsTextVKB; } void clearTextNeedsVKB() { _needsTextVKB = false; } + bool wantsWatchChannels() const { return _wantsWatchChannels; } + void clearWantsWatchChannels() { _wantsWatchChannels = false; } const char* getEditBuf() const { return _editBuf; } SettingsRowType getCurrentRowType() const { return _rows[_cursor].type; } void submitEditText(const char* text) { @@ -3798,12 +3801,18 @@ public: Serial.println("Settings: entered Contacts sub-screen"); break; case ROW_CHANNELS_SUBMENU: +#if defined(MECK_TWATCH) + // Watch: the desktop sub-screen's per-channel actions are keyboard + // hotkeys; hand off to the standalone WatchChannelConfigScreen. + _wantsWatchChannels = true; // UITask::loop() polls and opens it +#else _savedTopCursor = _cursor; _subScreen = SUB_CHANNELS; _cursor = 0; _scrollTop = 0; rebuildRows(); Serial.println("Settings: entered Channels sub-screen"); +#endif break; case ROW_RXLOG: diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 23086247..90bb4681 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -84,6 +84,7 @@ #endif #if defined(MECK_TWATCH) #include "WatchNotesScreen.h" // After UITask.h -- needs NodePrefs +#include "WatchChannelConfigScreen.h" // After UITask.h -- needs NodePrefs, the_mesh #endif #ifdef TWATCH_COMPOSE_ENABLED #include "TWatchComposeScreens.h" @@ -1798,6 +1799,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #if defined(MECK_TWATCH) // LittleFS ("/maps" partition) is mounted in setup() before UITask::begin(). watch_notes_screen = new WatchNotesScreen(this, &rtc_clock, node_prefs); + watch_channel_cfg_screen = new WatchChannelConfigScreen(this, node_prefs); #endif #ifdef TWATCH_COMPOSE_ENABLED tw_picker = new TWatchChannelPicker(_display); @@ -2613,6 +2615,13 @@ if (curr) curr->poll(); if (watch_notes_screen) setCurrScreen(watch_notes_screen); else gotoHomeScreen(); if (nerr) showAlert(nerr, 1200); + } else if (purpose == TWatchKeyboardScreen::TWKB_SCOPE) { + WatchChannelConfigScreen* wc = (WatchChannelConfigScreen*)watch_channel_cfg_screen; + const char* serr = wc ? wc->applyComposedScope(sendText) : "No channel cfg"; + kb->clearOutBuf(); + if (watch_channel_cfg_screen) setCurrScreen(watch_channel_cfg_screen); + else gotoHomeScreen(); + if (serr) showAlert(serr, 1200); } else { // TWKB_ADMIN_PASSWORD or TWKB_ADMIN_CLI RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen(); if (admin) { @@ -2642,6 +2651,27 @@ if (curr) curr->poll(); } } } + else if (curr == watch_channel_cfg_screen && watch_channel_cfg_screen != nullptr) { + WatchChannelConfigScreen* wc = (WatchChannelConfigScreen*)watch_channel_cfg_screen; + if (wc->wantsKeyboard()) { + wc->clearWantKeyboard(); + openTWatchKeyboard(TWatchKeyboardScreen::TWKB_SCOPE, 0); + ((TWatchKeyboardScreen*)tw_keyboard)->setInitialText(wc->getEditText()); + } else if (wc->wantsExit()) { + wc->clearWantExit(); + gotoSettingsScreen(); + } + } + else if (curr == settings_screen && settings_screen != nullptr) { + SettingsScreen* ss = (SettingsScreen*)settings_screen; + if (ss->wantsWatchChannels()) { + ss->clearWantsWatchChannels(); + if (watch_channel_cfg_screen) { + ((WatchChannelConfigScreen*)watch_channel_cfg_screen)->enter(); + setCurrScreen(watch_channel_cfg_screen); + } + } + } #endif if (_display != NULL && _display->isOn()) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index ae2ffb3f..e5f911da 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -144,6 +144,7 @@ class UITask : public AbstractUITask { #endif #if defined(MECK_TWATCH) UIScreen* watch_notes_screen; // LittleFS note pad (shared NotesScreen needs SD) + UIScreen* watch_channel_cfg_screen; // Per-channel region/notif/delete (touch UI) #endif UIScreen* _screenBeforeLock = nullptr; bool _locked = false; @@ -325,6 +326,7 @@ public: #endif #if defined(MECK_TWATCH) bool isOnWatchNotesScreen() const { return curr == watch_notes_screen; } + bool isOnWatchChannelConfigScreen() const { return curr == watch_channel_cfg_screen; } #endif bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; } bool isOnMapScreen() const { return curr == map_screen; } @@ -424,6 +426,7 @@ public: #endif #if defined(MECK_TWATCH) UIScreen* getWatchNotesScreen() const { return watch_notes_screen; } + UIScreen* getWatchChannelConfigScreen() const { return watch_channel_cfg_screen; } #endif UIScreen* getMinesweeperScreen() const { return minesweeper_screen; } UIScreen* getMapScreen() const { return map_screen; } diff --git a/variants/lilygo_twatch_s3/TWatchComposeScreens.h b/variants/lilygo_twatch_s3/TWatchComposeScreens.h index b51678f0..c2049659 100644 --- a/variants/lilygo_twatch_s3/TWatchComposeScreens.h +++ b/variants/lilygo_twatch_s3/TWatchComposeScreens.h @@ -417,7 +417,7 @@ class TWatchKeyboardScreen : public UIScreen { uint8_t _channelIdx; public: - enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE }; + enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE }; private: Purpose _purpose; diff --git a/variants/lilygo_twatch_s3/WatchChannelConfigScreen.h b/variants/lilygo_twatch_s3/WatchChannelConfigScreen.h new file mode 100644 index 00000000..9fb05280 --- /dev/null +++ b/variants/lilygo_twatch_s3/WatchChannelConfigScreen.h @@ -0,0 +1,382 @@ +#pragma once + +// ============================================================================= +// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus. +// +// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action +// from physical-keyboard hotkeys on the selected row (Ent: edit region scope, +// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the +// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds, +// following the WatchAlarmScreen / WatchNotesScreen conventions. +// +// LIST mode shows each channel with its region scope tag and notification +// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL +// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled +// with the current scope), Clear region (reverts to the device default), +// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order), +// and Delete via the button (channel 1+ only): a short press of the PWR key +// (S3) or the boot button (S3 Plus) arms it, a second press within 3s +// confirms. The tone picker is deliberately absent -- no audio path. +// +// Exit: the footer Back returns to the Settings screen (via a flag polled by +// UITask::loop); the universal top-strip tap still goes home. +// +// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120. +// ============================================================================= + +// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses +// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two +// call sites (UITask.cpp, main.cpp) already have in scope before including it. +#include +#include +#include +#include +#include +#include +#include + +class UITask; + +#ifndef MAX_GROUP_CHANNELS + #define MAX_GROUP_CHANNELS 20 +#endif + +#define WCC_ROWS 4 // list rows per page + +class WatchChannelConfigScreen : public UIScreen { +public: + enum Mode { LIST, DETAIL }; + +private: + UITask* _task; + NodePrefs* _node_prefs; + + uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order + int _numCh = 0; + + Mode _mode = LIST; + int _sel = 0; // selected list row (index into _chIdx) + int _scrollTop = 0; + int _detail = -1; // channel slot open in DETAIL (value from _chIdx) + bool _confirmDelete = false; + unsigned long _confirmAt = 0; + + // Keyboard / exit handshake -- UITask::loop() polls these (UITask is only + // forward declared here, so this screen cannot call it directly). + bool _wantKeyboard = false; + bool _wantExit = false; + char _editScope[31]; // pre-fill text handed to the keyboard + + // Layout (logical pixels) + static const int HEADER_H = 14; + static const int ROW_H = 20; + static const int FOOTER_Y = 100; + + void rebuildList() { + // Scan ALL slots -- the companion app may write non-contiguously, and + // gaps can appear after deletion (same scan as SettingsScreen). + _numCh = 0; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _chIdx[_numCh++] = i; + } + } + if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0; + if (_scrollTop > _sel) _scrollTop = _sel; + } + + const char* notifLabel(uint8_t idx) const { + uint8_t n = _node_prefs->channel_notif[idx]; + return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All"; + } + + // Replicates SettingsScreen::deleteChannel (kept in sync manually). Note: + // channel_notif[] is not shifted during compaction -- this matches the + // desktop behaviour exactly. + void deleteChannel(uint8_t idx) { + ChannelDetails empty; + memset(&empty, 0, sizeof(empty)); + int total = 0; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + total = i + 1; + } + } + for (int i = idx; i < total - 1; i++) { + ChannelDetails next; + if (the_mesh.getChannel(i + 1, next)) { + the_mesh.setChannel(i, next); + } + } + the_mesh.setChannel(total - 1, empty); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total); + } + + void ensureVisible() { + if (_sel < _scrollTop) _scrollTop = _sel; + if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1; + } + +public: + WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs) + : _task(task), _node_prefs(node_prefs) { + _editScope[0] = 0; + } + + void enter() { + rebuildList(); + _mode = LIST; + _detail = -1; + _confirmDelete = false; + _wantKeyboard = false; + _wantExit = false; + } + + // --- handshake (polled from UITask::loop) --- + bool wantsKeyboard() const { return _wantKeyboard; } + void clearWantKeyboard() { _wantKeyboard = false; } + bool wantsExit() const { return _wantExit; } + void clearWantExit() { _wantExit = false; } + const char* getEditText() const { return _editScope; } + + // Save the keyboard result as the open channel's region scope. An empty + // string clears it back to the device default (same as Clear). Returns + // NULL on success, else an error message. + const char* applyComposedScope(const char* txt) { + if (_detail < 0) return "No channel open"; + ChannelDetails ch; + if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone"; + if (txt == nullptr) txt = ""; + strncpy(ch.scope_name, txt, sizeof(ch.scope_name)); + ch.scope_name[30] = '\0'; + the_mesh.setChannel((uint8_t)_detail, ch); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n", + _detail, ch.scope_name[0] ? ch.scope_name : "(device default)"); + return nullptr; + } + + // --------------------------------------------------------------------------- + // Rendering. 120x120 logical. + // --------------------------------------------------------------------------- +private: + void renderList(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 4, "Channels"); + + if (_numCh == 0) { + d->setRawColor(0x7BEF); + display.drawTextCentered(display.width() / 2, 48, "No channels"); + } + + char buf[40]; + int visible = _numCh - _scrollTop; + if (visible > WCC_ROWS) visible = WCC_ROWS; + for (int i = 0; i < visible; i++) { + uint8_t idx = _chIdx[_scrollTop + i]; + int y = HEADER_H + i * ROW_H; + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) continue; + + if (_scrollTop + i == _sel) { + d->setRawColor(0x231D); // same blue as the home tile + d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3); + } + d->setRawColor(0xFFFF); + char nameBuf[15]; + strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1); + nameBuf[sizeof(nameBuf) - 1] = 0; + display.setCursor(5, y + 2); + display.print(nameBuf); + + snprintf(buf, sizeof(buf), "[%s] %s", + ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx)); + d->setRawColor(0x7BEF); + d->printSmallFont(5, y + 13, buf); + } + + // Footer: Back (left) and, when the list overflows a page, More (right). + d->setRawColor(0x231D); + d->drawRoundRect(1, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xFFFF); + d->printSmallFont(20, FOOTER_Y + 8, "Back"); + if (_numCh > WCC_ROWS) { + d->setRawColor(0x231D); + d->drawRoundRect(61, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xC618); + d->printSmallFont(78, FOOTER_Y + 8, "More"); + } + } + + void renderDetail(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + char buf[44]; + + ChannelDetails ch; + if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) { + _mode = LIST; + return; + } + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + char nameBuf[18]; + strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1); + nameBuf[sizeof(nameBuf) - 1] = 0; + display.drawTextCentered(display.width() / 2, 3, nameBuf); + + // Region row -- opens the keyboard. + d->setRawColor(0x231D); + d->drawRoundRect(1, 16, display.width() - 2, 18, 3); + snprintf(buf, sizeof(buf), "Region: %s", + ch.scope_name[0] ? ch.scope_name : "(default)"); + d->setRawColor(0xFFFF); + d->printSmallFont(6, 24, buf); + + // Clear-region row -- dim when there is nothing to clear. + d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3); + d->drawRoundRect(1, 36, display.width() - 2, 18, 3); + d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208); + d->printSmallFont(6, 44, "Clear region (use default)"); + + // Notify row -- tap cycles All -> Mentions -> Off. + d->setRawColor(0x231D); + d->drawRoundRect(1, 56, display.width() - 2, 18, 3); + uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail]; + snprintf(buf, sizeof(buf), "Notify: %s", + (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All"); + d->setRawColor(0xFFFF); + d->printSmallFont(6, 64, buf); + + // Delete row -- status only; deletion is driven by the button (see + // handleInput). Channel 0 (public) cannot be deleted, same as desktop. + bool canDel = (_detail > 0); + d->setRawColor(canDel ? 0x231D : 0x18C3); + d->drawRoundRect(1, 76, display.width() - 2, 18, 3); + d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208); + d->printSmallFont(6, 84, !canDel ? "Delete (public ch)" + : _confirmDelete ? "Delete? press again" : "Button: delete"); + + // Footer: Back to the list. + d->setRawColor(0x231D); + d->drawRoundRect(1, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xFFFF); + d->printSmallFont(20, FOOTER_Y + 8, "Back"); + } + +public: + int render(DisplayDriver& display) override { + if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false; + switch (_mode) { + case LIST: renderList(display); return 500; + case DETAIL: renderDetail(display); return 500; + } + return 500; + } + + // Physical tap in logical coordinates. The top-strip home tap is handled by + // main.cpp before this is called. Returns true if the tap was consumed. + bool handleTap(int lx, int ly) { + if (_mode == LIST) { + if (ly >= FOOTER_Y) { + if (lx < 60) { // Back -> Settings + _wantExit = true; + } else if (_numCh > WCC_ROWS) { // More: next page (wraps) + _scrollTop += WCC_ROWS; + if (_scrollTop >= _numCh) _scrollTop = 0; + _sel = _scrollTop; + } + return true; + } + if (ly >= HEADER_H) { + int i = (ly - HEADER_H) / ROW_H; + int idx = _scrollTop + i; + if (i < WCC_ROWS && idx < _numCh) { + if (idx == _sel) { // second tap on the row: open it + _detail = _chIdx[idx]; + _mode = DETAIL; + _confirmDelete = false; + } else { + _sel = idx; // first tap: select + ensureVisible(); + } + return true; + } + } + return false; + } + + // DETAIL + if (ly >= FOOTER_Y) { // Back -> list + _confirmDelete = false; + _mode = LIST; + rebuildList(); + return true; + } + ChannelDetails ch; + if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) { + _mode = LIST; + return true; + } + if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled + strncpy(_editScope, ch.scope_name, sizeof(_editScope)); + _editScope[30] = '\0'; + _wantKeyboard = true; + return true; + } + if (ly >= 36 && ly < 54) { // Clear region + if (ch.scope_name[0]) { + ch.scope_name[0] = '\0'; + the_mesh.setChannel((uint8_t)_detail, ch); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail); + } + return true; + } + if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off + uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail]; + _node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3; + the_mesh.savePrefs(); + return true; + } + return true; // swallow other taps (incl. delete row) + } + + bool handleInput(char c) override { + // The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button + // arrives as KEY_NEXT. + if (_mode == LIST && c == KEY_ENTER) { + if (_sel < _numCh) { + _detail = _chIdx[_sel]; + _mode = DETAIL; + _confirmDelete = false; + } + return true; + } + if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) { + // Button click deletes the open channel (channel 0 is protected): + // first press arms, a second press within 3s confirms. Back is the + // touch footer. + if (_detail > 0) { + if (_confirmDelete) { + deleteChannel((uint8_t)_detail); + _confirmDelete = false; + _detail = -1; + rebuildList(); + _mode = LIST; + } else { + _confirmDelete = true; + _confirmAt = millis(); + } + } + return true; + } + return false; + } +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3_plus/TWatchComposeScreens.h b/variants/lilygo_twatch_s3_plus/TWatchComposeScreens.h index b51678f0..c2049659 100644 --- a/variants/lilygo_twatch_s3_plus/TWatchComposeScreens.h +++ b/variants/lilygo_twatch_s3_plus/TWatchComposeScreens.h @@ -417,7 +417,7 @@ class TWatchKeyboardScreen : public UIScreen { uint8_t _channelIdx; public: - enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE }; + enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI, TWKB_PATH, TWKB_NOTE, TWKB_SCOPE }; private: Purpose _purpose; diff --git a/variants/lilygo_twatch_s3_plus/WatchChannelConfigScreen.h b/variants/lilygo_twatch_s3_plus/WatchChannelConfigScreen.h new file mode 100644 index 00000000..9fb05280 --- /dev/null +++ b/variants/lilygo_twatch_s3_plus/WatchChannelConfigScreen.h @@ -0,0 +1,382 @@ +#pragma once + +// ============================================================================= +// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus. +// +// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action +// from physical-keyboard hotkeys on the selected row (Ent: edit region scope, +// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the +// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds, +// following the WatchAlarmScreen / WatchNotesScreen conventions. +// +// LIST mode shows each channel with its region scope tag and notification +// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL +// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled +// with the current scope), Clear region (reverts to the device default), +// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order), +// and Delete via the button (channel 1+ only): a short press of the PWR key +// (S3) or the boot button (S3 Plus) arms it, a second press within 3s +// confirms. The tone picker is deliberately absent -- no audio path. +// +// Exit: the footer Back returns to the Settings screen (via a flag polled by +// UITask::loop); the universal top-strip tap still goes home. +// +// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120. +// ============================================================================= + +// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses +// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two +// call sites (UITask.cpp, main.cpp) already have in scope before including it. +#include +#include +#include +#include +#include +#include +#include + +class UITask; + +#ifndef MAX_GROUP_CHANNELS + #define MAX_GROUP_CHANNELS 20 +#endif + +#define WCC_ROWS 4 // list rows per page + +class WatchChannelConfigScreen : public UIScreen { +public: + enum Mode { LIST, DETAIL }; + +private: + UITask* _task; + NodePrefs* _node_prefs; + + uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order + int _numCh = 0; + + Mode _mode = LIST; + int _sel = 0; // selected list row (index into _chIdx) + int _scrollTop = 0; + int _detail = -1; // channel slot open in DETAIL (value from _chIdx) + bool _confirmDelete = false; + unsigned long _confirmAt = 0; + + // Keyboard / exit handshake -- UITask::loop() polls these (UITask is only + // forward declared here, so this screen cannot call it directly). + bool _wantKeyboard = false; + bool _wantExit = false; + char _editScope[31]; // pre-fill text handed to the keyboard + + // Layout (logical pixels) + static const int HEADER_H = 14; + static const int ROW_H = 20; + static const int FOOTER_Y = 100; + + void rebuildList() { + // Scan ALL slots -- the companion app may write non-contiguously, and + // gaps can appear after deletion (same scan as SettingsScreen). + _numCh = 0; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _chIdx[_numCh++] = i; + } + } + if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0; + if (_scrollTop > _sel) _scrollTop = _sel; + } + + const char* notifLabel(uint8_t idx) const { + uint8_t n = _node_prefs->channel_notif[idx]; + return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All"; + } + + // Replicates SettingsScreen::deleteChannel (kept in sync manually). Note: + // channel_notif[] is not shifted during compaction -- this matches the + // desktop behaviour exactly. + void deleteChannel(uint8_t idx) { + ChannelDetails empty; + memset(&empty, 0, sizeof(empty)); + int total = 0; + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + total = i + 1; + } + } + for (int i = idx; i < total - 1; i++) { + ChannelDetails next; + if (the_mesh.getChannel(i + 1, next)) { + the_mesh.setChannel(i, next); + } + } + the_mesh.setChannel(total - 1, empty); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total); + } + + void ensureVisible() { + if (_sel < _scrollTop) _scrollTop = _sel; + if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1; + } + +public: + WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs) + : _task(task), _node_prefs(node_prefs) { + _editScope[0] = 0; + } + + void enter() { + rebuildList(); + _mode = LIST; + _detail = -1; + _confirmDelete = false; + _wantKeyboard = false; + _wantExit = false; + } + + // --- handshake (polled from UITask::loop) --- + bool wantsKeyboard() const { return _wantKeyboard; } + void clearWantKeyboard() { _wantKeyboard = false; } + bool wantsExit() const { return _wantExit; } + void clearWantExit() { _wantExit = false; } + const char* getEditText() const { return _editScope; } + + // Save the keyboard result as the open channel's region scope. An empty + // string clears it back to the device default (same as Clear). Returns + // NULL on success, else an error message. + const char* applyComposedScope(const char* txt) { + if (_detail < 0) return "No channel open"; + ChannelDetails ch; + if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone"; + if (txt == nullptr) txt = ""; + strncpy(ch.scope_name, txt, sizeof(ch.scope_name)); + ch.scope_name[30] = '\0'; + the_mesh.setChannel((uint8_t)_detail, ch); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n", + _detail, ch.scope_name[0] ? ch.scope_name : "(device default)"); + return nullptr; + } + + // --------------------------------------------------------------------------- + // Rendering. 120x120 logical. + // --------------------------------------------------------------------------- +private: + void renderList(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 4, "Channels"); + + if (_numCh == 0) { + d->setRawColor(0x7BEF); + display.drawTextCentered(display.width() / 2, 48, "No channels"); + } + + char buf[40]; + int visible = _numCh - _scrollTop; + if (visible > WCC_ROWS) visible = WCC_ROWS; + for (int i = 0; i < visible; i++) { + uint8_t idx = _chIdx[_scrollTop + i]; + int y = HEADER_H + i * ROW_H; + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) continue; + + if (_scrollTop + i == _sel) { + d->setRawColor(0x231D); // same blue as the home tile + d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3); + } + d->setRawColor(0xFFFF); + char nameBuf[15]; + strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1); + nameBuf[sizeof(nameBuf) - 1] = 0; + display.setCursor(5, y + 2); + display.print(nameBuf); + + snprintf(buf, sizeof(buf), "[%s] %s", + ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx)); + d->setRawColor(0x7BEF); + d->printSmallFont(5, y + 13, buf); + } + + // Footer: Back (left) and, when the list overflows a page, More (right). + d->setRawColor(0x231D); + d->drawRoundRect(1, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xFFFF); + d->printSmallFont(20, FOOTER_Y + 8, "Back"); + if (_numCh > WCC_ROWS) { + d->setRawColor(0x231D); + d->drawRoundRect(61, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xC618); + d->printSmallFont(78, FOOTER_Y + 8, "More"); + } + } + + void renderDetail(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + char buf[44]; + + ChannelDetails ch; + if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) { + _mode = LIST; + return; + } + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + char nameBuf[18]; + strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1); + nameBuf[sizeof(nameBuf) - 1] = 0; + display.drawTextCentered(display.width() / 2, 3, nameBuf); + + // Region row -- opens the keyboard. + d->setRawColor(0x231D); + d->drawRoundRect(1, 16, display.width() - 2, 18, 3); + snprintf(buf, sizeof(buf), "Region: %s", + ch.scope_name[0] ? ch.scope_name : "(default)"); + d->setRawColor(0xFFFF); + d->printSmallFont(6, 24, buf); + + // Clear-region row -- dim when there is nothing to clear. + d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3); + d->drawRoundRect(1, 36, display.width() - 2, 18, 3); + d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208); + d->printSmallFont(6, 44, "Clear region (use default)"); + + // Notify row -- tap cycles All -> Mentions -> Off. + d->setRawColor(0x231D); + d->drawRoundRect(1, 56, display.width() - 2, 18, 3); + uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail]; + snprintf(buf, sizeof(buf), "Notify: %s", + (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All"); + d->setRawColor(0xFFFF); + d->printSmallFont(6, 64, buf); + + // Delete row -- status only; deletion is driven by the button (see + // handleInput). Channel 0 (public) cannot be deleted, same as desktop. + bool canDel = (_detail > 0); + d->setRawColor(canDel ? 0x231D : 0x18C3); + d->drawRoundRect(1, 76, display.width() - 2, 18, 3); + d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208); + d->printSmallFont(6, 84, !canDel ? "Delete (public ch)" + : _confirmDelete ? "Delete? press again" : "Button: delete"); + + // Footer: Back to the list. + d->setRawColor(0x231D); + d->drawRoundRect(1, FOOTER_Y, 58, 18, 3); + d->setRawColor(0xFFFF); + d->printSmallFont(20, FOOTER_Y + 8, "Back"); + } + +public: + int render(DisplayDriver& display) override { + if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false; + switch (_mode) { + case LIST: renderList(display); return 500; + case DETAIL: renderDetail(display); return 500; + } + return 500; + } + + // Physical tap in logical coordinates. The top-strip home tap is handled by + // main.cpp before this is called. Returns true if the tap was consumed. + bool handleTap(int lx, int ly) { + if (_mode == LIST) { + if (ly >= FOOTER_Y) { + if (lx < 60) { // Back -> Settings + _wantExit = true; + } else if (_numCh > WCC_ROWS) { // More: next page (wraps) + _scrollTop += WCC_ROWS; + if (_scrollTop >= _numCh) _scrollTop = 0; + _sel = _scrollTop; + } + return true; + } + if (ly >= HEADER_H) { + int i = (ly - HEADER_H) / ROW_H; + int idx = _scrollTop + i; + if (i < WCC_ROWS && idx < _numCh) { + if (idx == _sel) { // second tap on the row: open it + _detail = _chIdx[idx]; + _mode = DETAIL; + _confirmDelete = false; + } else { + _sel = idx; // first tap: select + ensureVisible(); + } + return true; + } + } + return false; + } + + // DETAIL + if (ly >= FOOTER_Y) { // Back -> list + _confirmDelete = false; + _mode = LIST; + rebuildList(); + return true; + } + ChannelDetails ch; + if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) { + _mode = LIST; + return true; + } + if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled + strncpy(_editScope, ch.scope_name, sizeof(_editScope)); + _editScope[30] = '\0'; + _wantKeyboard = true; + return true; + } + if (ly >= 36 && ly < 54) { // Clear region + if (ch.scope_name[0]) { + ch.scope_name[0] = '\0'; + the_mesh.setChannel((uint8_t)_detail, ch); + the_mesh.saveChannels(); + Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail); + } + return true; + } + if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off + uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail]; + _node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3; + the_mesh.savePrefs(); + return true; + } + return true; // swallow other taps (incl. delete row) + } + + bool handleInput(char c) override { + // The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button + // arrives as KEY_NEXT. + if (_mode == LIST && c == KEY_ENTER) { + if (_sel < _numCh) { + _detail = _chIdx[_sel]; + _mode = DETAIL; + _confirmDelete = false; + } + return true; + } + if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) { + // Button click deletes the open channel (channel 0 is protected): + // first press arms, a second press within 3s confirms. Back is the + // touch footer. + if (_detail > 0) { + if (_confirmDelete) { + deleteChannel((uint8_t)_detail); + _confirmDelete = false; + _detail = -1; + rebuildList(); + _mode = LIST; + } else { + _confirmDelete = true; + _confirmAt = millis(); + } + } + return true; + } + return false; + } +}; \ No newline at end of file