mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
Extract RingtoneEditorScreen, BotScreen, ToolsScreen to separate headers
Move three custom screens out of UITask.cpp into their own .h files (RingtoneEditorScreen.h, BotScreen.h, ToolsScreen.h) and replace them with three #include lines. UITask.cpp now only contains screens that overlap with upstream (SplashScreen, SettingsScreen, QuickMsgScreen, HomeScreen), making future upstream merges easier — custom screens live in files that upstream will never touch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
363
examples/companion_radio/ui-new/BotScreen.h
Normal file
363
examples/companion_radio/ui-new/BotScreen.h
Normal file
@@ -0,0 +1,363 @@
|
||||
#pragma once
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp after the global KB_* constants are defined.
|
||||
|
||||
class BotScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
static const int ITEM_COUNT = 4;
|
||||
static const int ITEM_H = 11;
|
||||
static const int START_Y = 12;
|
||||
static const int VAL_X = 70;
|
||||
|
||||
// keyboard state (reused for trigger and reply fields)
|
||||
int _kb_field; // -1=off, 2=trigger, 3=reply
|
||||
char _kb_buf[KB_MAX_LEN + 1];
|
||||
int _kb_len;
|
||||
int _kb_maxlen;
|
||||
int _kb_row, _kb_col;
|
||||
bool _kb_caps;
|
||||
bool _kb_ph_mode;
|
||||
int _kb_ph_sel;
|
||||
|
||||
int _sel;
|
||||
|
||||
// channel + DM contact caches (refreshed on enter)
|
||||
static const int MAX_BOT_DM = 16;
|
||||
|
||||
int _num_channels;
|
||||
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
|
||||
|
||||
int _num_dm;
|
||||
uint8_t _dm_pubkeys[MAX_BOT_DM][4];
|
||||
char _dm_names[MAX_BOT_DM][32];
|
||||
|
||||
void refreshChannels() {
|
||||
_num_channels = 0;
|
||||
ChannelDetails ch;
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0')
|
||||
_channel_indices[_num_channels++] = (uint8_t)i;
|
||||
}
|
||||
}
|
||||
|
||||
void refreshContacts() {
|
||||
_num_dm = 0;
|
||||
ContactInfo ci;
|
||||
int n = the_mesh.getNumContacts();
|
||||
for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) {
|
||||
if (!the_mesh.getContactByIdx(i, ci)) continue;
|
||||
if (ci.type != ADV_TYPE_CHAT) continue;
|
||||
if (!(ci.flags & 0x01)) continue; // favourites only
|
||||
memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4);
|
||||
strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1);
|
||||
_dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0';
|
||||
_num_dm++;
|
||||
}
|
||||
}
|
||||
|
||||
// returns combined index in [channels..., DM contacts...]
|
||||
int currentTargetIdx() const {
|
||||
if (_prefs->bot_target_type == 0) {
|
||||
for (int i = 0; i < _num_channels; i++)
|
||||
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
|
||||
return 0;
|
||||
} else {
|
||||
for (int i = 0; i < _num_dm; i++)
|
||||
if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0)
|
||||
return _num_channels + i;
|
||||
return _num_channels; // fallback: first DM
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() {
|
||||
_sel = 0;
|
||||
_kb_field = -1;
|
||||
refreshChannels();
|
||||
refreshContacts();
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
if (_kb_field >= 0) {
|
||||
// keyboard mode — uses same layout as global keyboard
|
||||
const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:";
|
||||
const char* disp_start = _kb_buf;
|
||||
int disp_len = _kb_len;
|
||||
if (disp_len > 20) { disp_start = _kb_buf + (disp_len - 20); disp_len = 20; }
|
||||
char preview[28];
|
||||
snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start);
|
||||
display.setCursor(0, KB_TEXT_Y);
|
||||
display.print(preview);
|
||||
display.fillRect(0, KB_SEP_Y, display.width(), 1);
|
||||
|
||||
// char rows
|
||||
for (int row = 0; row < KB_ROWS_CHAR; row++) {
|
||||
int y = KB_CHARS_Y + row * KB_CELL_H;
|
||||
for (int col = 0; col < KB_COLS_CHAR; col++) {
|
||||
bool sel = (_kb_row == row && _kb_col == col);
|
||||
char ch = KB_CHARS[row][col];
|
||||
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
|
||||
char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' };
|
||||
int cx = col * KB_CELL_W;
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(cx + 3, y);
|
||||
display.print(ch_buf);
|
||||
}
|
||||
}
|
||||
|
||||
// special row: [^] [Sp] [Del] [{}] [OK] — 5 buttons at 25px each
|
||||
const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" };
|
||||
for (int i = 0; i < KB_SPECIAL; i++) {
|
||||
bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i);
|
||||
bool active = (i == 0 && _kb_caps);
|
||||
int sx = i * 25;
|
||||
if (sel || active) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(sx + 1, KB_SPECIAL_Y);
|
||||
display.print(spec[i]);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// placeholder picker overlay
|
||||
if (_kb_ph_mode) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
|
||||
display.setCursor(24, 21);
|
||||
display.print("Placeholder:");
|
||||
display.fillRect(20, 30, 88, 1);
|
||||
for (int i = 0; i < KB_PH_COUNT; i++) {
|
||||
int py = 33 + i * 10;
|
||||
if (i == _kb_ph_sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(21, py - 1, 86, 10);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(24, py);
|
||||
display.print(KB_PH_LIST[i]);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
return 50;
|
||||
}
|
||||
|
||||
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" };
|
||||
for (int i = 0; i < ITEM_COUNT; i++) {
|
||||
int y = START_Y + i * ITEM_H;
|
||||
bool sel = (i == _sel);
|
||||
if (sel) {
|
||||
display.fillRect(0, y - 1, display.width(), ITEM_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
}
|
||||
display.setCursor(2, y);
|
||||
if (i == 1)
|
||||
display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact");
|
||||
else
|
||||
display.print(labels[i]);
|
||||
display.setCursor(VAL_X, y);
|
||||
|
||||
if (i == 0) {
|
||||
display.print(_prefs->bot_enabled ? "ON" : "OFF");
|
||||
} else if (i == 1) {
|
||||
int total = _num_channels + _num_dm;
|
||||
if (total == 0) {
|
||||
display.print("none");
|
||||
} else if (_prefs->bot_target_type == 0) {
|
||||
ChannelDetails ch;
|
||||
if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
|
||||
else
|
||||
display.print("?");
|
||||
} else {
|
||||
bool found = false;
|
||||
for (int j = 0; j < _num_dm && !found; j++) {
|
||||
if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) {
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) display.print("?");
|
||||
}
|
||||
} else if (i == 2) {
|
||||
const char* tr = _prefs->bot_trigger;
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)");
|
||||
} else {
|
||||
const char* rp = _prefs->bot_reply;
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, 54);
|
||||
display.print("ENT:edit CANCEL:save");
|
||||
return 300;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
bool up = (c == KEY_UP);
|
||||
bool down = (c == KEY_DOWN);
|
||||
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
||||
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);
|
||||
|
||||
if (_kb_field >= 0) {
|
||||
// placeholder picker overlay takes priority
|
||||
if (_kb_ph_mode) {
|
||||
if (c == KEY_UP && _kb_ph_sel > 0) { _kb_ph_sel--; return true; }
|
||||
if (c == KEY_DOWN && _kb_ph_sel < KB_PH_COUNT - 1) { _kb_ph_sel++; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
const char* ph = KB_PH_LIST[_kb_ph_sel];
|
||||
int ph_len = strlen(ph);
|
||||
if (_kb_len + ph_len <= _kb_maxlen) {
|
||||
memcpy(_kb_buf + _kb_len, ph, ph_len);
|
||||
_kb_len += ph_len;
|
||||
_kb_buf[_kb_len] = '\0';
|
||||
}
|
||||
_kb_ph_mode = false;
|
||||
return true;
|
||||
}
|
||||
if (cancel) { _kb_ph_mode = false; return true; }
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) { _kb_field = -1; return true; }
|
||||
if (up) {
|
||||
if (_kb_row > 0) {
|
||||
_kb_row--;
|
||||
if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward
|
||||
_kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (down) {
|
||||
if (_kb_row < KB_ROWS_CHAR) {
|
||||
_kb_row++;
|
||||
if (_kb_row == KB_ROWS_CHAR) // entering special row
|
||||
_kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (left) {
|
||||
if (_kb_col > 0) _kb_col--;
|
||||
return true;
|
||||
}
|
||||
if (right) {
|
||||
int max_col = (_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1;
|
||||
if (_kb_col < max_col) _kb_col++;
|
||||
return true;
|
||||
}
|
||||
if (enter) {
|
||||
if (_kb_row < KB_ROWS_CHAR) {
|
||||
if (_kb_len < _kb_maxlen) {
|
||||
char ch = KB_CHARS[_kb_row][_kb_col];
|
||||
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
|
||||
_kb_buf[_kb_len++] = ch;
|
||||
_kb_buf[_kb_len] = '\0';
|
||||
}
|
||||
} else {
|
||||
switch (_kb_col) {
|
||||
case 0: _kb_caps = !_kb_caps; break;
|
||||
case 1:
|
||||
if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; }
|
||||
break;
|
||||
case 2:
|
||||
if (_kb_len > 0) _kb_buf[--_kb_len] = '\0';
|
||||
break;
|
||||
case 3:
|
||||
_kb_ph_mode = true;
|
||||
_kb_ph_sel = 0;
|
||||
break;
|
||||
case 4:
|
||||
// OK — commit to prefs
|
||||
if (_kb_field == 2) {
|
||||
strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1);
|
||||
_prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0';
|
||||
} else {
|
||||
strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1);
|
||||
_prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0';
|
||||
}
|
||||
_kb_field = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) {
|
||||
the_mesh.savePrefs();
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (up && _sel > 0) { _sel--; return true; }
|
||||
if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; }
|
||||
|
||||
if (_sel == 0 && (enter || left || right)) {
|
||||
_prefs->bot_enabled ^= 1;
|
||||
return true;
|
||||
}
|
||||
if (_sel == 1) {
|
||||
int total = _num_channels + _num_dm;
|
||||
if (total == 0) return false;
|
||||
int idx = currentTargetIdx();
|
||||
if (right || enter) idx = (idx + 1) % total;
|
||||
if (left) idx = (idx + total - 1) % total;
|
||||
if (left || right || enter) {
|
||||
if (idx < _num_channels) {
|
||||
_prefs->bot_target_type = 0;
|
||||
_prefs->bot_channel_idx = _channel_indices[idx];
|
||||
} else {
|
||||
int di = idx - _num_channels;
|
||||
_prefs->bot_target_type = 1;
|
||||
memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ((_sel == 2 || _sel == 3) && enter) {
|
||||
_kb_field = _sel;
|
||||
_kb_row = 0;
|
||||
_kb_col = 0;
|
||||
_kb_caps = false;
|
||||
_kb_ph_mode = false;
|
||||
_kb_ph_sel = 0;
|
||||
if (_sel == 2) {
|
||||
strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1);
|
||||
_kb_maxlen = sizeof(_prefs->bot_trigger) - 1;
|
||||
} else {
|
||||
strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1);
|
||||
_kb_maxlen = sizeof(_prefs->bot_reply) - 1;
|
||||
}
|
||||
_kb_buf[sizeof(_kb_buf) - 1] = '\0';
|
||||
_kb_len = strlen(_kb_buf);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
318
examples/companion_radio/ui-new/RingtoneEditorScreen.h
Normal file
318
examples/companion_radio/ui-new/RingtoneEditorScreen.h
Normal file
@@ -0,0 +1,318 @@
|
||||
#pragma once
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp after the global KB_* constants are defined.
|
||||
|
||||
class RingtoneEditorScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
static const int MAX_NOTES = 32;
|
||||
static const int VISIBLE_NOTES = 7;
|
||||
static const int CELL_W = 18;
|
||||
static const int NOTES_Y = 12;
|
||||
static const int CELL_H = 14;
|
||||
static const int MENU_VISIBLE = 5;
|
||||
|
||||
static const uint16_t BPM_OPTS[5];
|
||||
static const uint8_t DUR_VALS[4];
|
||||
static const char* DUR_LABELS[4];
|
||||
static const char PITCH_NAMES[8]; // lowercase rtttl names
|
||||
|
||||
uint8_t _notes[MAX_NOTES];
|
||||
uint8_t _len;
|
||||
uint8_t _bpm_idx;
|
||||
int _cursor;
|
||||
int _scroll;
|
||||
bool _menu_open;
|
||||
int _menu_sel;
|
||||
int _menu_scroll;
|
||||
|
||||
enum MenuOpt {
|
||||
M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN,
|
||||
M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT
|
||||
};
|
||||
static const char* MENU_LABELS[M_COUNT];
|
||||
|
||||
static uint8_t notePitch(uint8_t b) { return b & 0x07; }
|
||||
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; }
|
||||
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; }
|
||||
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) {
|
||||
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
|
||||
}
|
||||
|
||||
void clampScroll() {
|
||||
if (_cursor < _scroll) _scroll = _cursor;
|
||||
if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1;
|
||||
if (_scroll < 0) _scroll = 0;
|
||||
}
|
||||
|
||||
void buildRTTTL(char* buf, int buf_len) {
|
||||
uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2];
|
||||
int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm);
|
||||
for (int i = 0; i < _len && pos < buf_len - 8; i++) {
|
||||
if (i > 0 && pos < buf_len - 1) buf[pos++] = ',';
|
||||
uint8_t pitch = notePitch(_notes[i]);
|
||||
uint8_t octave = noteOctave(_notes[i]);
|
||||
uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])];
|
||||
if (pitch == 0)
|
||||
pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val);
|
||||
else
|
||||
pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave);
|
||||
}
|
||||
if (pos < buf_len) buf[pos] = '\0';
|
||||
}
|
||||
|
||||
void previewNote(uint8_t note_byte) {
|
||||
uint8_t pitch = notePitch(note_byte);
|
||||
if (pitch == 0) { _task->stopMelody(); return; }
|
||||
char buf[28];
|
||||
snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte));
|
||||
_task->playMelody(buf);
|
||||
}
|
||||
|
||||
public:
|
||||
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() {
|
||||
_bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2;
|
||||
_len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0;
|
||||
if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes));
|
||||
_cursor = 0;
|
||||
_scroll = 0;
|
||||
_menu_open = false;
|
||||
_menu_sel = 0;
|
||||
_menu_scroll = 0;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
char hdr[32];
|
||||
snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
|
||||
display.setCursor(0, 0);
|
||||
display.print(hdr);
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
for (int i = 0; i < VISIBLE_NOTES; i++) {
|
||||
int ni = _scroll + i;
|
||||
int cx = i * CELL_W;
|
||||
bool sel = (ni == _cursor);
|
||||
if (ni < _len) {
|
||||
uint8_t pitch = notePitch(_notes[ni]);
|
||||
uint8_t octave = noteOctave(_notes[ni]);
|
||||
char label[3];
|
||||
if (pitch == 0) {
|
||||
label[0] = '-'; label[1] = '-'; label[2] = '\0';
|
||||
} else {
|
||||
label[0] = PITCH_NAMES[pitch] - 32; // uppercase
|
||||
label[1] = '0' + octave;
|
||||
label[2] = '\0';
|
||||
}
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
}
|
||||
display.setCursor(cx + 3, NOTES_Y + 3);
|
||||
display.print(label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
} else if (ni == _len && _len < MAX_NOTES) {
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(cx + 6, NOTES_Y + 3);
|
||||
display.print("+");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
int info_y = NOTES_Y + CELL_H + 2;
|
||||
if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); }
|
||||
if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); }
|
||||
if (_cursor < _len) {
|
||||
char info[24];
|
||||
snprintf(info, sizeof(info), "oct:%d dur:%s",
|
||||
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
|
||||
display.setCursor(10, info_y);
|
||||
display.print(info);
|
||||
} else if (_cursor == _len) {
|
||||
display.setCursor(10, info_y);
|
||||
display.print("U/D to add note");
|
||||
}
|
||||
|
||||
display.setCursor(0, 54);
|
||||
display.print("ENT:oct MENU:opts");
|
||||
|
||||
if (_menu_open) {
|
||||
static const int mw = 100, mh = MENU_VISIBLE * 10 + 4;
|
||||
int mx = (128 - mw) / 2;
|
||||
int my = (64 - mh) / 2;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(mx, my, mw, mh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(mx, my, mw, mh);
|
||||
for (int i = 0; i < MENU_VISIBLE; i++) {
|
||||
int item = _menu_scroll + i;
|
||||
if (item >= M_COUNT) break;
|
||||
int iy = my + 2 + i * 10;
|
||||
bool sel = (item == _menu_sel);
|
||||
if (sel) {
|
||||
display.fillRect(mx + 1, iy - 1, mw - 2, 10);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
}
|
||||
display.setCursor(mx + 4, iy);
|
||||
if (item == M_PLAY_STOP)
|
||||
display.print(_task->isMelodyPlaying() ? "Stop" : "Play");
|
||||
else
|
||||
display.print(MENU_LABELS[item]);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); }
|
||||
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); }
|
||||
}
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
bool up = (c == KEY_UP);
|
||||
bool down = (c == KEY_DOWN);
|
||||
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
||||
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
bool menu_key = (c == KEY_CONTEXT_MENU);
|
||||
bool cancel = (c == KEY_CANCEL);
|
||||
|
||||
if (_menu_open) {
|
||||
if (cancel) { _menu_open = false; return true; }
|
||||
if (up && _menu_sel > 0) {
|
||||
_menu_sel--;
|
||||
if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel;
|
||||
return true;
|
||||
}
|
||||
if (down && _menu_sel < M_COUNT - 1) {
|
||||
_menu_sel++;
|
||||
if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1;
|
||||
return true;
|
||||
}
|
||||
if (!enter) return true;
|
||||
|
||||
_menu_open = false;
|
||||
switch ((MenuOpt)_menu_sel) {
|
||||
case M_PLAY_STOP:
|
||||
if (_task->isMelodyPlaying()) {
|
||||
_task->stopMelody();
|
||||
} else if (_len > 0) {
|
||||
char buf[220];
|
||||
buildRTTTL(buf, sizeof(buf));
|
||||
_task->playMelody(buf);
|
||||
}
|
||||
break;
|
||||
case M_DURATION:
|
||||
if (_cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
}
|
||||
break;
|
||||
case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break;
|
||||
case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break;
|
||||
case M_INSERT:
|
||||
if (_len < MAX_NOTES) {
|
||||
int ins = (_cursor < _len) ? _cursor + 1 : _cursor;
|
||||
for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1];
|
||||
_notes[ins] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
_cursor = ins;
|
||||
clampScroll();
|
||||
}
|
||||
break;
|
||||
case M_DELETE:
|
||||
if (_len > 0 && _cursor < _len) {
|
||||
for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1];
|
||||
_len--;
|
||||
if (_cursor >= _len && _len > 0) _cursor = _len - 1;
|
||||
else if (_len == 0) _cursor = 0;
|
||||
clampScroll();
|
||||
}
|
||||
break;
|
||||
case M_SAVE:
|
||||
if (_prefs) {
|
||||
_prefs->ringtone_bpm_idx = _bpm_idx;
|
||||
_prefs->ringtone_len = _len;
|
||||
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_task->stopMelody();
|
||||
_task->gotoToolsScreen();
|
||||
break;
|
||||
case M_DISCARD:
|
||||
_task->stopMelody();
|
||||
_task->gotoToolsScreen();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; }
|
||||
if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; }
|
||||
|
||||
if (left && _cursor > 0) { _cursor--; clampScroll(); return true; }
|
||||
if (right) {
|
||||
int max_cur = (_len < MAX_NOTES) ? _len : _len - 1;
|
||||
if (_cursor < max_cur) { _cursor++; clampScroll(); return true; }
|
||||
}
|
||||
|
||||
if ((up || down) && _cursor == _len && _len < MAX_NOTES) {
|
||||
_notes[_len] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
clampScroll();
|
||||
}
|
||||
if ((up || down) && _cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = noteDurIdx(_notes[_cursor]);
|
||||
if (up) p = (p + 1) & 0x07;
|
||||
if (down) p = (p + 7) & 0x07;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (enter && _cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = noteDurIdx(_notes[_cursor]);
|
||||
if (p != 0) o = (o < 6) ? o + 1 : 4;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
if (enter && _cursor == _len && _len < MAX_NOTES) {
|
||||
_notes[_len] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
clampScroll();
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 };
|
||||
const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 };
|
||||
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
|
||||
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
|
||||
const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = {
|
||||
"Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
|
||||
};
|
||||
51
examples/companion_radio/ui-new/ToolsScreen.h
Normal file
51
examples/companion_radio/ui-new/ToolsScreen.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp just before HomeScreen.
|
||||
|
||||
class ToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
int _sel;
|
||||
|
||||
static const int ITEM_COUNT = 2;
|
||||
static const char* ITEMS[ITEM_COUNT];
|
||||
|
||||
public:
|
||||
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
for (int i = 0; i < ITEM_COUNT; i++) {
|
||||
int y = 12 + i * 12;
|
||||
bool sel = (i == _sel);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y - 1, display.width(), 11);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(0, y);
|
||||
display.print(sel ? ">" : " ");
|
||||
display.setCursor(8, y);
|
||||
display.print(ITEMS[i]);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
return 500;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_UP && _sel > 0) { _sel--; return true; }
|
||||
if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
|
||||
if (_sel == 1) { _task->gotoBotScreen(); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" };
|
||||
@@ -1744,731 +1744,10 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
// ── RingtoneEditorScreen ──────────────────────────────────────────────────────
|
||||
class RingtoneEditorScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
static const int MAX_NOTES = 32;
|
||||
static const int VISIBLE_NOTES = 7;
|
||||
static const int CELL_W = 18;
|
||||
static const int NOTES_Y = 12;
|
||||
static const int CELL_H = 14;
|
||||
static const int MENU_VISIBLE = 5;
|
||||
|
||||
static const uint16_t BPM_OPTS[5];
|
||||
static const uint8_t DUR_VALS[4];
|
||||
static const char* DUR_LABELS[4];
|
||||
static const char PITCH_NAMES[8]; // lowercase rtttl names
|
||||
|
||||
uint8_t _notes[MAX_NOTES];
|
||||
uint8_t _len;
|
||||
uint8_t _bpm_idx;
|
||||
int _cursor;
|
||||
int _scroll;
|
||||
bool _menu_open;
|
||||
int _menu_sel;
|
||||
int _menu_scroll;
|
||||
|
||||
enum MenuOpt {
|
||||
M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN,
|
||||
M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT
|
||||
};
|
||||
static const char* MENU_LABELS[M_COUNT];
|
||||
|
||||
static uint8_t notePitch(uint8_t b) { return b & 0x07; }
|
||||
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; }
|
||||
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; }
|
||||
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) {
|
||||
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
|
||||
}
|
||||
|
||||
void clampScroll() {
|
||||
if (_cursor < _scroll) _scroll = _cursor;
|
||||
if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1;
|
||||
if (_scroll < 0) _scroll = 0;
|
||||
}
|
||||
|
||||
void buildRTTTL(char* buf, int buf_len) {
|
||||
uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2];
|
||||
int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm);
|
||||
for (int i = 0; i < _len && pos < buf_len - 8; i++) {
|
||||
if (i > 0 && pos < buf_len - 1) buf[pos++] = ',';
|
||||
uint8_t pitch = notePitch(_notes[i]);
|
||||
uint8_t octave = noteOctave(_notes[i]);
|
||||
uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])];
|
||||
if (pitch == 0)
|
||||
pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val);
|
||||
else
|
||||
pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave);
|
||||
}
|
||||
if (pos < buf_len) buf[pos] = '\0';
|
||||
}
|
||||
|
||||
void previewNote(uint8_t note_byte) {
|
||||
uint8_t pitch = notePitch(note_byte);
|
||||
if (pitch == 0) { _task->stopMelody(); return; }
|
||||
char buf[28];
|
||||
snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte));
|
||||
_task->playMelody(buf);
|
||||
}
|
||||
|
||||
public:
|
||||
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() {
|
||||
_bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2;
|
||||
_len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0;
|
||||
if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes));
|
||||
_cursor = 0;
|
||||
_scroll = 0;
|
||||
_menu_open = false;
|
||||
_menu_sel = 0;
|
||||
_menu_scroll = 0;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
char hdr[32];
|
||||
snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
|
||||
display.setCursor(0, 0);
|
||||
display.print(hdr);
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
for (int i = 0; i < VISIBLE_NOTES; i++) {
|
||||
int ni = _scroll + i;
|
||||
int cx = i * CELL_W;
|
||||
bool sel = (ni == _cursor);
|
||||
if (ni < _len) {
|
||||
uint8_t pitch = notePitch(_notes[ni]);
|
||||
uint8_t octave = noteOctave(_notes[ni]);
|
||||
char label[3];
|
||||
if (pitch == 0) {
|
||||
label[0] = '-'; label[1] = '-'; label[2] = '\0';
|
||||
} else {
|
||||
label[0] = PITCH_NAMES[pitch] - 32; // uppercase
|
||||
label[1] = '0' + octave;
|
||||
label[2] = '\0';
|
||||
}
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
}
|
||||
display.setCursor(cx + 3, NOTES_Y + 3);
|
||||
display.print(label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
} else if (ni == _len && _len < MAX_NOTES) {
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(cx + 6, NOTES_Y + 3);
|
||||
display.print("+");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
int info_y = NOTES_Y + CELL_H + 2;
|
||||
if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); }
|
||||
if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); }
|
||||
if (_cursor < _len) {
|
||||
char info[24];
|
||||
snprintf(info, sizeof(info), "oct:%d dur:%s",
|
||||
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
|
||||
display.setCursor(10, info_y);
|
||||
display.print(info);
|
||||
} else if (_cursor == _len) {
|
||||
display.setCursor(10, info_y);
|
||||
display.print("U/D to add note");
|
||||
}
|
||||
|
||||
display.setCursor(0, 54);
|
||||
display.print("ENT:oct MENU:opts");
|
||||
|
||||
if (_menu_open) {
|
||||
static const int mw = 100, mh = MENU_VISIBLE * 10 + 4;
|
||||
int mx = (128 - mw) / 2;
|
||||
int my = (64 - mh) / 2;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(mx, my, mw, mh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(mx, my, mw, mh);
|
||||
for (int i = 0; i < MENU_VISIBLE; i++) {
|
||||
int item = _menu_scroll + i;
|
||||
if (item >= M_COUNT) break;
|
||||
int iy = my + 2 + i * 10;
|
||||
bool sel = (item == _menu_sel);
|
||||
if (sel) {
|
||||
display.fillRect(mx + 1, iy - 1, mw - 2, 10);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
}
|
||||
display.setCursor(mx + 4, iy);
|
||||
if (item == M_PLAY_STOP)
|
||||
display.print(_task->isMelodyPlaying() ? "Stop" : "Play");
|
||||
else
|
||||
display.print(MENU_LABELS[item]);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); }
|
||||
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); }
|
||||
}
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
bool up = (c == KEY_UP);
|
||||
bool down = (c == KEY_DOWN);
|
||||
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
||||
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
bool menu_key = (c == KEY_CONTEXT_MENU);
|
||||
bool cancel = (c == KEY_CANCEL);
|
||||
|
||||
if (_menu_open) {
|
||||
if (cancel) { _menu_open = false; return true; }
|
||||
if (up && _menu_sel > 0) {
|
||||
_menu_sel--;
|
||||
if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel;
|
||||
return true;
|
||||
}
|
||||
if (down && _menu_sel < M_COUNT - 1) {
|
||||
_menu_sel++;
|
||||
if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1;
|
||||
return true;
|
||||
}
|
||||
if (!enter) return true;
|
||||
|
||||
_menu_open = false;
|
||||
switch ((MenuOpt)_menu_sel) {
|
||||
case M_PLAY_STOP:
|
||||
if (_task->isMelodyPlaying()) {
|
||||
_task->stopMelody();
|
||||
} else if (_len > 0) {
|
||||
char buf[220];
|
||||
buildRTTTL(buf, sizeof(buf));
|
||||
_task->playMelody(buf);
|
||||
}
|
||||
break;
|
||||
case M_DURATION:
|
||||
if (_cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
}
|
||||
break;
|
||||
case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break;
|
||||
case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break;
|
||||
case M_INSERT:
|
||||
if (_len < MAX_NOTES) {
|
||||
int ins = (_cursor < _len) ? _cursor + 1 : _cursor;
|
||||
for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1];
|
||||
_notes[ins] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
_cursor = ins;
|
||||
clampScroll();
|
||||
}
|
||||
break;
|
||||
case M_DELETE:
|
||||
if (_len > 0 && _cursor < _len) {
|
||||
for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1];
|
||||
_len--;
|
||||
if (_cursor >= _len && _len > 0) _cursor = _len - 1;
|
||||
else if (_len == 0) _cursor = 0;
|
||||
clampScroll();
|
||||
}
|
||||
break;
|
||||
case M_SAVE:
|
||||
if (_prefs) {
|
||||
_prefs->ringtone_bpm_idx = _bpm_idx;
|
||||
_prefs->ringtone_len = _len;
|
||||
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
|
||||
the_mesh.savePrefs();
|
||||
}
|
||||
_task->stopMelody();
|
||||
_task->gotoToolsScreen();
|
||||
break;
|
||||
case M_DISCARD:
|
||||
_task->stopMelody();
|
||||
_task->gotoToolsScreen();
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; }
|
||||
if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; }
|
||||
|
||||
if (left && _cursor > 0) { _cursor--; clampScroll(); return true; }
|
||||
if (right) {
|
||||
int max_cur = (_len < MAX_NOTES) ? _len : _len - 1;
|
||||
if (_cursor < max_cur) { _cursor++; clampScroll(); return true; }
|
||||
}
|
||||
|
||||
if ((up || down) && _cursor == _len && _len < MAX_NOTES) {
|
||||
_notes[_len] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
clampScroll();
|
||||
}
|
||||
if ((up || down) && _cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = noteDurIdx(_notes[_cursor]);
|
||||
if (up) p = (p + 1) & 0x07;
|
||||
if (down) p = (p + 7) & 0x07;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (enter && _cursor < _len) {
|
||||
uint8_t p = notePitch(_notes[_cursor]);
|
||||
uint8_t o = noteOctave(_notes[_cursor]);
|
||||
uint8_t di = noteDurIdx(_notes[_cursor]);
|
||||
if (p != 0) o = (o < 6) ? o + 1 : 4;
|
||||
_notes[_cursor] = packNote(p, o, di);
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
if (enter && _cursor == _len && _len < MAX_NOTES) {
|
||||
_notes[_len] = packNote(1, 5, 1);
|
||||
_len++;
|
||||
clampScroll();
|
||||
previewNote(_notes[_cursor]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 };
|
||||
const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 };
|
||||
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
|
||||
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
|
||||
const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = {
|
||||
"Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
|
||||
};
|
||||
|
||||
// ── BotScreen ─────────────────────────────────────────────────────────────────
|
||||
class BotScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
static const int ITEM_COUNT = 4;
|
||||
static const int ITEM_H = 11;
|
||||
static const int START_Y = 12;
|
||||
static const int VAL_X = 70;
|
||||
|
||||
// keyboard state (reused for trigger and reply fields)
|
||||
int _kb_field; // -1=off, 2=trigger, 3=reply
|
||||
char _kb_buf[KB_MAX_LEN + 1];
|
||||
int _kb_len;
|
||||
int _kb_maxlen;
|
||||
int _kb_row, _kb_col;
|
||||
bool _kb_caps;
|
||||
bool _kb_ph_mode;
|
||||
int _kb_ph_sel;
|
||||
|
||||
int _sel;
|
||||
|
||||
// channel + DM contact caches (refreshed on enter)
|
||||
static const int MAX_BOT_DM = 16;
|
||||
|
||||
int _num_channels;
|
||||
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
|
||||
|
||||
int _num_dm;
|
||||
uint8_t _dm_pubkeys[MAX_BOT_DM][4];
|
||||
char _dm_names[MAX_BOT_DM][32];
|
||||
|
||||
void refreshChannels() {
|
||||
_num_channels = 0;
|
||||
ChannelDetails ch;
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0')
|
||||
_channel_indices[_num_channels++] = (uint8_t)i;
|
||||
}
|
||||
}
|
||||
|
||||
void refreshContacts() {
|
||||
_num_dm = 0;
|
||||
ContactInfo ci;
|
||||
int n = the_mesh.getNumContacts();
|
||||
for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) {
|
||||
if (!the_mesh.getContactByIdx(i, ci)) continue;
|
||||
if (ci.type != ADV_TYPE_CHAT) continue;
|
||||
if (!(ci.flags & 0x01)) continue; // favourites only
|
||||
memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4);
|
||||
strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1);
|
||||
_dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0';
|
||||
_num_dm++;
|
||||
}
|
||||
}
|
||||
|
||||
// returns combined index in [channels..., DM contacts...]
|
||||
int currentTargetIdx() const {
|
||||
if (_prefs->bot_target_type == 0) {
|
||||
for (int i = 0; i < _num_channels; i++)
|
||||
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
|
||||
return 0;
|
||||
} else {
|
||||
for (int i = 0; i < _num_dm; i++)
|
||||
if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0)
|
||||
return _num_channels + i;
|
||||
return _num_channels; // fallback: first DM
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
|
||||
|
||||
void enter() {
|
||||
_sel = 0;
|
||||
_kb_field = -1;
|
||||
refreshChannels();
|
||||
refreshContacts();
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
if (_kb_field >= 0) {
|
||||
// keyboard mode — uses same layout as global keyboard
|
||||
const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:";
|
||||
const char* disp_start = _kb_buf;
|
||||
int disp_len = _kb_len;
|
||||
if (disp_len > 20) { disp_start = _kb_buf + (disp_len - 20); disp_len = 20; }
|
||||
char preview[28];
|
||||
snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start);
|
||||
display.setCursor(0, KB_TEXT_Y);
|
||||
display.print(preview);
|
||||
display.fillRect(0, KB_SEP_Y, display.width(), 1);
|
||||
|
||||
// char rows
|
||||
for (int row = 0; row < KB_ROWS_CHAR; row++) {
|
||||
int y = KB_CHARS_Y + row * KB_CELL_H;
|
||||
for (int col = 0; col < KB_COLS_CHAR; col++) {
|
||||
bool sel = (_kb_row == row && _kb_col == col);
|
||||
char ch = KB_CHARS[row][col];
|
||||
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
|
||||
char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' };
|
||||
int cx = col * KB_CELL_W;
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(cx + 3, y);
|
||||
display.print(ch_buf);
|
||||
}
|
||||
}
|
||||
|
||||
// special row: [^] [Sp] [Del] [{}] [OK] — 5 buttons at 25px each
|
||||
const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" };
|
||||
for (int i = 0; i < KB_SPECIAL; i++) {
|
||||
bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i);
|
||||
bool active = (i == 0 && _kb_caps);
|
||||
int sx = i * 25;
|
||||
if (sel || active) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(sx + 1, KB_SPECIAL_Y);
|
||||
display.print(spec[i]);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// placeholder picker overlay
|
||||
if (_kb_ph_mode) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
|
||||
display.setCursor(24, 21);
|
||||
display.print("Placeholder:");
|
||||
display.fillRect(20, 30, 88, 1);
|
||||
for (int i = 0; i < KB_PH_COUNT; i++) {
|
||||
int py = 33 + i * 10;
|
||||
if (i == _kb_ph_sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(21, py - 1, 86, 10);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(24, py);
|
||||
display.print(KB_PH_LIST[i]);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
return 50;
|
||||
}
|
||||
|
||||
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" };
|
||||
for (int i = 0; i < ITEM_COUNT; i++) {
|
||||
int y = START_Y + i * ITEM_H;
|
||||
bool sel = (i == _sel);
|
||||
if (sel) {
|
||||
display.fillRect(0, y - 1, display.width(), ITEM_H);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
}
|
||||
display.setCursor(2, y);
|
||||
if (i == 1)
|
||||
display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact");
|
||||
else
|
||||
display.print(labels[i]);
|
||||
display.setCursor(VAL_X, y);
|
||||
|
||||
if (i == 0) {
|
||||
display.print(_prefs->bot_enabled ? "ON" : "OFF");
|
||||
} else if (i == 1) {
|
||||
int total = _num_channels + _num_dm;
|
||||
if (total == 0) {
|
||||
display.print("none");
|
||||
} else if (_prefs->bot_target_type == 0) {
|
||||
ChannelDetails ch;
|
||||
if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
|
||||
else
|
||||
display.print("?");
|
||||
} else {
|
||||
bool found = false;
|
||||
for (int j = 0; j < _num_dm && !found; j++) {
|
||||
if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) {
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if (!found) display.print("?");
|
||||
}
|
||||
} else if (i == 2) {
|
||||
const char* tr = _prefs->bot_trigger;
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)");
|
||||
} else {
|
||||
const char* rp = _prefs->bot_reply;
|
||||
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, 54);
|
||||
display.print("ENT:edit CANCEL:save");
|
||||
return 300;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
bool up = (c == KEY_UP);
|
||||
bool down = (c == KEY_DOWN);
|
||||
bool left = (c == KEY_LEFT || c == KEY_PREV);
|
||||
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
|
||||
bool enter = (c == KEY_ENTER);
|
||||
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);
|
||||
|
||||
if (_kb_field >= 0) {
|
||||
// placeholder picker overlay takes priority
|
||||
if (_kb_ph_mode) {
|
||||
if (c == KEY_UP && _kb_ph_sel > 0) { _kb_ph_sel--; return true; }
|
||||
if (c == KEY_DOWN && _kb_ph_sel < KB_PH_COUNT - 1) { _kb_ph_sel++; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
const char* ph = KB_PH_LIST[_kb_ph_sel];
|
||||
int ph_len = strlen(ph);
|
||||
if (_kb_len + ph_len <= _kb_maxlen) {
|
||||
memcpy(_kb_buf + _kb_len, ph, ph_len);
|
||||
_kb_len += ph_len;
|
||||
_kb_buf[_kb_len] = '\0';
|
||||
}
|
||||
_kb_ph_mode = false;
|
||||
return true;
|
||||
}
|
||||
if (cancel) { _kb_ph_mode = false; return true; }
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) { _kb_field = -1; return true; }
|
||||
if (up) {
|
||||
if (_kb_row > 0) {
|
||||
_kb_row--;
|
||||
if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward
|
||||
_kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (down) {
|
||||
if (_kb_row < KB_ROWS_CHAR) {
|
||||
_kb_row++;
|
||||
if (_kb_row == KB_ROWS_CHAR) // entering special row
|
||||
_kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (left) {
|
||||
if (_kb_col > 0) _kb_col--;
|
||||
return true;
|
||||
}
|
||||
if (right) {
|
||||
int max_col = (_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1;
|
||||
if (_kb_col < max_col) _kb_col++;
|
||||
return true;
|
||||
}
|
||||
if (enter) {
|
||||
if (_kb_row < KB_ROWS_CHAR) {
|
||||
if (_kb_len < _kb_maxlen) {
|
||||
char ch = KB_CHARS[_kb_row][_kb_col];
|
||||
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
|
||||
_kb_buf[_kb_len++] = ch;
|
||||
_kb_buf[_kb_len] = '\0';
|
||||
}
|
||||
} else {
|
||||
switch (_kb_col) {
|
||||
case 0: _kb_caps = !_kb_caps; break;
|
||||
case 1:
|
||||
if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; }
|
||||
break;
|
||||
case 2:
|
||||
if (_kb_len > 0) _kb_buf[--_kb_len] = '\0';
|
||||
break;
|
||||
case 3:
|
||||
_kb_ph_mode = true;
|
||||
_kb_ph_sel = 0;
|
||||
break;
|
||||
case 4:
|
||||
// OK — commit to prefs
|
||||
if (_kb_field == 2) {
|
||||
strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1);
|
||||
_prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0';
|
||||
} else {
|
||||
strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1);
|
||||
_prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0';
|
||||
}
|
||||
_kb_field = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cancel) {
|
||||
the_mesh.savePrefs();
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (up && _sel > 0) { _sel--; return true; }
|
||||
if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; }
|
||||
|
||||
if (_sel == 0 && (enter || left || right)) {
|
||||
_prefs->bot_enabled ^= 1;
|
||||
return true;
|
||||
}
|
||||
if (_sel == 1) {
|
||||
int total = _num_channels + _num_dm;
|
||||
if (total == 0) return false;
|
||||
int idx = currentTargetIdx();
|
||||
if (right || enter) idx = (idx + 1) % total;
|
||||
if (left) idx = (idx + total - 1) % total;
|
||||
if (left || right || enter) {
|
||||
if (idx < _num_channels) {
|
||||
_prefs->bot_target_type = 0;
|
||||
_prefs->bot_channel_idx = _channel_indices[idx];
|
||||
} else {
|
||||
int di = idx - _num_channels;
|
||||
_prefs->bot_target_type = 1;
|
||||
memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ((_sel == 2 || _sel == 3) && enter) {
|
||||
_kb_field = _sel;
|
||||
_kb_row = 0;
|
||||
_kb_col = 0;
|
||||
_kb_caps = false;
|
||||
_kb_ph_mode = false;
|
||||
_kb_ph_sel = 0;
|
||||
if (_sel == 2) {
|
||||
strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1);
|
||||
_kb_maxlen = sizeof(_prefs->bot_trigger) - 1;
|
||||
} else {
|
||||
strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1);
|
||||
_kb_maxlen = sizeof(_prefs->bot_reply) - 1;
|
||||
}
|
||||
_kb_buf[sizeof(_kb_buf) - 1] = '\0';
|
||||
_kb_len = strlen(_kb_buf);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── ToolsScreen ───────────────────────────────────────────────────────────────
|
||||
class ToolsScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
int _sel;
|
||||
|
||||
static const int ITEM_COUNT = 2;
|
||||
static const char* ITEMS[ITEM_COUNT];
|
||||
|
||||
public:
|
||||
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
|
||||
display.fillRect(0, 10, display.width(), 1);
|
||||
|
||||
for (int i = 0; i < ITEM_COUNT; i++) {
|
||||
int y = 12 + i * 12;
|
||||
bool sel = (i == _sel);
|
||||
if (sel) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, y - 1, display.width(), 11);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.setCursor(0, y);
|
||||
display.print(sel ? ">" : " ");
|
||||
display.setCursor(8, y);
|
||||
display.print(ITEMS[i]);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
return 500;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_UP && _sel > 0) { _sel--; return true; }
|
||||
if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; }
|
||||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
|
||||
if (_sel == 1) { _task->gotoBotScreen(); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" };
|
||||
// ── Custom screens (separate files to ease upstream merges) ───────────────────
|
||||
#include "RingtoneEditorScreen.h"
|
||||
#include "BotScreen.h"
|
||||
#include "ToolsScreen.h"
|
||||
|
||||
// ── HomeScreen ────────────────────────────────────────────────────────────────
|
||||
class HomeScreen : public UIScreen {
|
||||
|
||||
Reference in New Issue
Block a user