feat(ui): Favourites home page (phase 1: storage + read-only grid)

New home page rendering 6 pinned-contact slots. Grid transposes to
orientation: landscape → 3×2, portrait → 2×3. Joystick claims UP/DOWN
and inner LEFT/RIGHT for grid nav; edge LEFT/RIGHT fall through to
page navigation. Selected tile inverts via drawSelectionRow, filled
tile shows contact name + unread badge, empty tile shows "+".

Storage:
- NodePrefs::favourite_contacts[6][6] (6-byte pub_key prefix per slot,
  all-zero = empty; collision probability with a real key is 2^-48)
- HomePageBit gains HPB_FAVOURITES = 11 (HPB_COUNT = 12), with a new
  HP_FAVOURITES mask bit so the page is toggleable in Settings
- New PAGE_ORDER_LEN constant decouples the persisted array length (11)
  from the page-type count; loops over page_order now use it, value-range
  checks still use HPB_COUNT
- homePageBit returns 0 for HPB_SETTINGS / HPB_QUICK_MSG explicitly
  rather than by bit-index range, so HPB_FAVOURITES (bit 11) is correctly
  treated as toggleable
- Default order inserts FAVOURITES after CLOCK; SHUTDOWN drops to the
  fallback "missing pages" append (still reachable, lands at end)

Schema sentinel bumped to 0xC0DE0002. DataStore::loadPrefsInt and
savePrefs read/write the new field. Older saves leave the field
zero-initialised (all slots empty), which is the natural starting state.

Phase 2 (Pin from Contact options) and Phase 3 (Pin from empty-slot
mini picker) wire interactions; this commit handles render + nav only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-24 23:00:27 +02:00
parent 3e4e85e1ff
commit 21c02c7114
5 changed files with 166 additions and 49 deletions

View File

@@ -304,6 +304,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
_prefs.page_order_set = NodePrefs::PAGE_ORDER_MAGIC;
}
rd(_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel.
@@ -394,6 +396,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation));
file.write((uint8_t *)&_prefs.eink_full_refresh_every, sizeof(_prefs.eink_full_refresh_every));
file.write((uint8_t *)&_prefs.page_order_set, sizeof(_prefs.page_order_set));
file.write((uint8_t *)_prefs.favourite_contacts, sizeof(_prefs.favourite_contacts));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -92,49 +92,63 @@ struct NodePrefs { // persisted to file
uint8_t page_order_set; // 0xA5 = page_order is user-configured; anything else = use default
static const uint8_t PAGE_ORDER_MAGIC = 0xA5;
// Favourites dial: 6 pinned contacts, stored as first 6 bytes of pub_key per slot.
// All-zero = empty slot (probability a real pub_key starts with 6 zero bytes is 2^-48).
// Layout transposes between landscape (3×2) and portrait (2×3).
static const uint8_t FAVOURITES_COUNT = 6;
static const uint8_t FAVOURITE_PREFIX_LEN = 6;
uint8_t favourite_contacts[FAVOURITES_COUNT][FAVOURITE_PREFIX_LEN];
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0001;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0002;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
// and SettingsScreen::homePageBitIndex route their per-enum lookups through these.
enum HomePageBit : uint8_t {
HPB_CLOCK = 0,
HPB_RECENT = 1,
HPB_RADIO = 2,
HPB_BLUETOOTH = 3,
HPB_ADVERT = 4,
HPB_GPS = 5,
HPB_SENSORS = 6,
HPB_TOOLS = 7,
HPB_SHUTDOWN = 8,
HPB_SETTINGS = 9,
HPB_QUICK_MSG = 10,
HPB_COUNT = 11,
HPB_CLOCK = 0,
HPB_RECENT = 1,
HPB_RADIO = 2,
HPB_BLUETOOTH = 3,
HPB_ADVERT = 4,
HPB_GPS = 5,
HPB_SENSORS = 6,
HPB_TOOLS = 7,
HPB_SHUTDOWN = 8,
HPB_SETTINGS = 9,
HPB_QUICK_MSG = 10,
HPB_FAVOURITES = 11,
HPB_COUNT = 12,
};
// Length of the persisted page_order[] array. Stable across firmware versions
// (don't grow without a schema migration). When HPB_COUNT exceeds this, extra
// visible pages are appended at navigation time via buildVisibleOrder fallback.
static const uint8_t PAGE_ORDER_LEN = 11;
// Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible).
// SETTINGS and QUICK_MSG have no mask bit — they're always visible.
static const uint16_t HP_CLOCK = 1 << HPB_CLOCK;
static const uint16_t HP_RECENT = 1 << HPB_RECENT;
static const uint16_t HP_RADIO = 1 << HPB_RADIO;
static const uint16_t HP_BLUETOOTH = 1 << HPB_BLUETOOTH;
static const uint16_t HP_ADVERT = 1 << HPB_ADVERT;
static const uint16_t HP_GPS = 1 << HPB_GPS;
static const uint16_t HP_SENSORS = 1 << HPB_SENSORS;
static const uint16_t HP_TOOLS = 1 << HPB_TOOLS;
static const uint16_t HP_SHUTDOWN = 1 << HPB_SHUTDOWN;
static const uint16_t HP_ALL = 0x01FF;
static const uint16_t HP_CLOCK = 1 << HPB_CLOCK;
static const uint16_t HP_RECENT = 1 << HPB_RECENT;
static const uint16_t HP_RADIO = 1 << HPB_RADIO;
static const uint16_t HP_BLUETOOTH = 1 << HPB_BLUETOOTH;
static const uint16_t HP_ADVERT = 1 << HPB_ADVERT;
static const uint16_t HP_GPS = 1 << HPB_GPS;
static const uint16_t HP_SENSORS = 1 << HPB_SENSORS;
static const uint16_t HP_TOOLS = 1 << HPB_TOOLS;
static const uint16_t HP_SHUTDOWN = 1 << HPB_SHUTDOWN;
static const uint16_t HP_FAVOURITES = 1 << HPB_FAVOURITES;
static const uint16_t HP_ALL = 0x01FF | HP_FAVOURITES;
// Label for home page by bit-index; returns "" for out-of-range.
// Array indices match HomePageBit values.
static const char* homePageLabel(uint8_t bit) {
static const char* labels[HPB_COUNT] = {
"Clock", "Recent", "Radio", "Bluetooth", "Advert",
"GPS", "Sensors", "Tools", "Shutdown", "Settings", "Messages"
"GPS", "Sensors", "Tools", "Shutdown", "Settings", "Messages", "Favourites"
};
return (bit < HPB_COUNT) ? labels[bit] : "";
}

View File

@@ -40,7 +40,7 @@ class SettingsScreen : public UIScreen {
CH_MELODY,
// Home pages section
SECTION_HOME_PAGES,
HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT,
HOME_CLOCK, HOME_FAVOURITES, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT,
#if ENV_INCLUDE_GPS == 1
HOME_GPS,
#endif
@@ -164,9 +164,10 @@ class SettingsScreen : public UIScreen {
}
bool isHomePage(int item) const {
return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO ||
item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS ||
item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG
return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO ||
item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS ||
item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG ||
item == HOME_FAVOURITES
#if ENV_INCLUDE_GPS == 1
|| item == HOME_GPS
#endif
@@ -178,9 +179,10 @@ class SettingsScreen : public UIScreen {
uint16_t homePageBit(int item) const {
int bit = homePageBitIndex(item);
// Bits 0..HPB_SHUTDOWN have mask bits in home_pages_mask; SETTINGS and
// QUICK_MSG are always visible (no mask bit) and return 0.
return (bit >= 0 && bit < NodePrefs::HPB_SETTINGS) ? (uint16_t)(1 << bit) : 0;
// SETTINGS and QUICK_MSG are always visible (no mask bit). All other pages
// — including FAVOURITES — toggle via home_pages_mask.
if (bit < 0 || bit == NodePrefs::HPB_SETTINGS || bit == NodePrefs::HPB_QUICK_MSG) return 0;
return (uint16_t)(1 << bit);
}
const char* homePageLabel(int item) const {
@@ -204,6 +206,7 @@ class SettingsScreen : public UIScreen {
// Bit-index values are defined once in NodePrefs::HomePageBit.
int homePageBitIndex(int item) const {
if (item == HOME_CLOCK) return NodePrefs::HPB_CLOCK;
if (item == HOME_FAVOURITES) return NodePrefs::HPB_FAVOURITES;
if (item == HOME_RECENT) return NodePrefs::HPB_RECENT;
if (item == HOME_RADIO) return NodePrefs::HPB_RADIO;
if (item == HOME_BT) return NodePrefs::HPB_BLUETOOTH;
@@ -226,7 +229,7 @@ class SettingsScreen : public UIScreen {
if (!p || p->page_order_set != NodePrefs::PAGE_ORDER_MAGIC) return 0;
int bit = homePageBitIndex(item);
if (bit < 0) return 0;
for (int i = 0; i < NodePrefs::HPB_COUNT; i++) {
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) return i + 1;
@@ -248,24 +251,27 @@ class SettingsScreen : public UIScreen {
// CLOCK missing — reset and fall through to full re-init.
memset(p->page_order, 0, sizeof(p->page_order));
}
// Default: CLOCK RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES SHUTDOWN
// Default: CLOCK FAVOURITES RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES
// SHUTDOWN is omitted from the explicit list (PAGE_ORDER_LEN = 11 leaves room for the
// common case GPS+SENSORS+all-others) and appended by buildVisibleOrder's missing-page
// fallback at the end of the navigation sequence.
int j = 0;
p->page_order[j++] = 0 + 1; // CLOCK
p->page_order[j++] = 1 + 1; // RECENT
p->page_order[j++] = 2 + 1; // RADIO
p->page_order[j++] = 3 + 1; // BLUETOOTH
p->page_order[j++] = 4 + 1; // ADVERT
p->page_order[j++] = NodePrefs::HPB_CLOCK + 1;
p->page_order[j++] = NodePrefs::HPB_FAVOURITES + 1;
p->page_order[j++] = NodePrefs::HPB_RECENT + 1;
p->page_order[j++] = NodePrefs::HPB_RADIO + 1;
p->page_order[j++] = NodePrefs::HPB_BLUETOOTH + 1;
p->page_order[j++] = NodePrefs::HPB_ADVERT + 1;
#if ENV_INCLUDE_GPS == 1
p->page_order[j++] = 5 + 1; // GPS
p->page_order[j++] = NodePrefs::HPB_GPS + 1;
#endif
#if UI_SENSORS_PAGE == 1
p->page_order[j++] = 6 + 1; // SENSORS
p->page_order[j++] = NodePrefs::HPB_SENSORS + 1;
#endif
p->page_order[j++] = 9 + 1; // SETTINGS
p->page_order[j++] = 7 + 1; // TOOLS
p->page_order[j++] = 10 + 1; // MESSAGES (QUICK_MSG)
p->page_order[j++] = 8 + 1; // SHUTDOWN
while (j < NodePrefs::HPB_COUNT) p->page_order[j++] = 0;
p->page_order[j++] = NodePrefs::HPB_SETTINGS + 1;
p->page_order[j++] = NodePrefs::HPB_TOOLS + 1;
p->page_order[j++] = NodePrefs::HPB_QUICK_MSG + 1;
while (j < NodePrefs::PAGE_ORDER_LEN) p->page_order[j++] = 0;
p->page_order_set = NodePrefs::PAGE_ORDER_MAGIC;
}
@@ -275,7 +281,7 @@ class SettingsScreen : public UIScreen {
int bit = homePageBitIndex(item);
if (bit < 0) return;
int cur = -1, total = 0;
for (int i = 0; i < NodePrefs::HPB_COUNT; i++) {
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) cur = i;

View File

@@ -122,6 +122,7 @@ static const int QUICK_MSGS_MAX = 10;
class HomeScreen : public UIScreen {
enum HomePage {
CLOCK,
FAVOURITES,
RECENT,
RADIO,
BLUETOOTH,
@@ -139,6 +140,9 @@ class HomeScreen : public UIScreen {
Count // keep as last
};
// Selected slot on the Favourites page (0..FAVOURITES_COUNT - 1).
uint8_t _fav_sel = 0;
UITask* _task;
mesh::RTCClock* _rtc;
SensorManager* _sensors;
@@ -148,7 +152,8 @@ class HomeScreen : public UIScreen {
AdvertPath recent[UI_RECENT_LIST_SIZE];
int pageBit(int page) const {
if (page == CLOCK) return NodePrefs::HPB_CLOCK;
if (page == CLOCK) return NodePrefs::HPB_CLOCK;
if (page == FAVOURITES) return NodePrefs::HPB_FAVOURITES;
if (page == RECENT) return NodePrefs::HPB_RECENT;
if (page == RADIO) return NodePrefs::HPB_RADIO;
if (page == BLUETOOTH) return NodePrefs::HPB_BLUETOOTH;
@@ -168,7 +173,8 @@ class HomeScreen : public UIScreen {
// Returns -1 if the page is not compiled in.
int bitToPage(int bit) const {
switch (bit) {
case NodePrefs::HPB_CLOCK: return CLOCK;
case NodePrefs::HPB_CLOCK: return CLOCK;
case NodePrefs::HPB_FAVOURITES: return FAVOURITES;
case NodePrefs::HPB_RECENT: return RECENT;
case NodePrefs::HPB_RADIO: return RADIO;
case NodePrefs::HPB_BLUETOOTH: return BLUETOOTH;
@@ -200,7 +206,7 @@ class HomeScreen : public UIScreen {
int n = 0;
bool custom = _node_prefs && _node_prefs->page_order_set == NodePrefs::PAGE_ORDER_MAGIC;
if (custom) {
for (int i = 0; i < NodePrefs::HPB_COUNT; i++) {
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = _node_prefs->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
int pg = bitToPage(v - 1);
@@ -738,6 +744,72 @@ public:
display.drawTextCentered(display.width() / 2, content_y + step, badge);
}
display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open");
} else if (_page == HomePage::FAVOURITES) {
// Grid of pinned contacts. Layout transposes to current orientation:
// landscape → 3×2, portrait → 2×3. Selected tile inverts via drawSelectionRow.
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 0, "FAVOURITES");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const int cols = display.isLandscape() ? 3 : 2;
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
const int grid_y = display.headerH() + display.sepH();
const int grid_h = display.height() - grid_y;
const int cell_w = display.width() / cols;
const int cell_h = grid_h / rows;
const int lh = display.getLineHeight();
if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0;
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
int row = i / cols;
int col = i % cols;
int cx = col * cell_w;
int cy = grid_y + row * cell_h;
bool sel = (i == _fav_sel);
display.drawSelectionRow(cx, cy, cell_w - 1, cell_h - 1, sel);
// Empty slot → all-zero prefix. Real keys collide with this with probability 2^-48.
const uint8_t* prefix = _node_prefs ? _node_prefs->favourite_contacts[i] : nullptr;
bool filled = false;
if (prefix) {
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (prefix[b] != 0) { filled = true; break; }
}
if (filled) {
ContactInfo ci;
bool found = false;
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
ci = c; found = true; break;
}
}
char name[24];
if (found) display.translateUTF8ToBlocks(name, ci.name, sizeof(name));
else strncpy(name, "(gone)", sizeof(name) - 1), name[sizeof(name) - 1] = '\0';
int name_y = cy + (cell_h - lh) / 2;
display.drawTextEllipsized(cx + 2, name_y, cell_w - 4, name);
if (found) {
uint8_t unread = _task->getDMUnread(ci.id.pub_key);
if (unread > 0) {
char badge[5];
snprintf(badge, sizeof(badge), "%d", unread);
int bw = display.getTextWidth(badge);
display.setCursor(cx + cell_w - bw - 2, cy + 1);
display.print(badge);
}
}
} else {
int plus_y = cy + (cell_h - lh) / 2;
display.drawTextCentered(cx + cell_w / 2, plus_y, "+");
}
if (sel) display.setColor(DisplayDriver::LIGHT);
}
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(1);
@@ -773,6 +845,27 @@ public:
}
bool handleInput(char c) override {
// Favourites grid claims joystick UP/DOWN and inner LEFT/RIGHT; LEFT at the
// left column and RIGHT at the right column fall through to page nav so the
// user can still leave the page sideways.
if (_page == HomePage::FAVOURITES) {
DisplayDriver* d = _task->getDisplay();
const int cols = (d && d->isLandscape()) ? 3 : 2;
const int rows = NodePrefs::FAVOURITES_COUNT / cols;
int col = _fav_sel % cols;
int row = _fav_sel / cols;
if ((c == KEY_LEFT || c == KEY_PREV) && col > 0) { _fav_sel--; return true; }
if ((c == KEY_RIGHT || c == KEY_NEXT) && col < cols - 1) { _fav_sel++; return true; }
if (c == KEY_UP && row > 0) { _fav_sel -= cols; return true; }
if (c == KEY_DOWN && row < rows - 1) { _fav_sel += cols; return true; }
if (c == KEY_ENTER) {
// Phase 2/3 wires this: open DM if filled, picker if empty. Consume for now.
_task->showAlert("Pin from contact options", 1000);
return true;
}
// Edge LEFT/RIGHT and unhandled keys fall through to page nav below.
}
if (c == KEY_LEFT || c == KEY_PREV) {
_page = navPage(_page, -1);
return true;

View File

@@ -138,6 +138,7 @@ public:
}
void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); }
bool hasDisplay() const { return _display != NULL; }
DisplayDriver* getDisplay() const { return _display; }
bool isButtonPressed() const;
bool isBuzzerQuiet() {