From 3293ac09e3dade65dea66fec330816702622b104 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:24:44 +0000 Subject: [PATCH] refactor(ui-lvgl): centerRow and pillButton helpers; document the shared text-entry popup and bubble row Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VeBrxKyHHxWsmttVkUdBrj --- docs/solo/developer/ui-core.md | 2 +- docs/solo/developer/ui-framework.md | 2 + .../companion_radio/ui-lvgl/AdminScreen.h | 19 ++----- .../companion_radio/ui-lvgl/ClockScreen.h | 19 ++----- examples/companion_radio/ui-lvgl/UITask.cpp | 50 +++++++++++-------- 5 files changed, 41 insertions(+), 51 deletions(-) diff --git a/docs/solo/developer/ui-core.md b/docs/solo/developer/ui-core.md index 168c68db..0065065d 100644 --- a/docs/solo/developer/ui-core.md +++ b/docs/solo/developer/ui-core.md @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds five pages -- map options (trail, live share, arrival alert; ui-lvgl opens it from the map's tools), advert (auto-advert; in ui-lvgl's advert popup, the quick panel's Advert), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds, units) messages & contacts (DM resends, contact expiry, favourites first) and sound (volume, the sound for direct messages / channels / adverts, advert sound scope). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page (`schemaRows()` draws a page's rows into any container, a screen or a popup); ui-new puts a schema section's rows into its own accordion sections (`SCHEMA_DISPLAY`, `SCHEMA_SYSTEM`, … in `SettingsScreen.h`), next to the rows it still draws itself (radio, keyboard, home pages, quick messages). Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off), and ui-new's lock clock look (`lock_compact`, Big / Compact), which ui-lvgl leaves out of its rows. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, font card, counter reset), shown by ui-new's Home > Status screen (Mesh and System tabs) and ui-lvgl's Home > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (the built-in LiPo curve or the user's `batt_curve_mv`, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Home > Repeater use it, MyMesh does the relaying. Sound is `ui-core/SoundControl.h` (`soundctl::`: the On / Off / Auto mode over `buzzer_quiet` + `buzzer_auto`, the volume with its preview, the built-in sounds, the two user melodies as an editable note list) and `ui-core/SoundNotifier.h` (which sound a message / advert plays: global choice, per-contact / per-channel alert and melody overrides); the schema's Sound page holds the volume and the PLAYS FOR choices, the channel melody override is `chanctl::melody`. Both frontends drive a `genericBuzzer`: a PWM piezo on L1, the ES8311 codec on the L2 (`BUZZER_I2S`: an audio task synthesises the notes and counts samples, the board powers the amp through `buzzerAmpPower()`). Outgoing text is `ui-core/MessageText.h` (`msgtext::`: the ten quick messages, the placeholders on offer -- `{loc}`, `{time}`, `{batt}`, live sensor readings -- and expanding them at send time, keeping a reply's `@[name] ` prefix as typed); ui-new's Messages / keyboard and ui-lvgl's compose "+" use it. `UiCore::markAllRead()` clears every unread counter; `MyMesh::advertFlood()` (formerly sim-only) sends the self-advert through repeaters, `advert()` zero-hop. ui-lvgl's screens share their widgets from `UITask.cpp`: `segmented()` (a one-of-N button row; `radioview::rowSegmented` puts one at the right of a settings row), `textField()` (a one-line field padded to exactly one line), `listRow`, `switchRow`, `headerButton`. +`ui-core/SettingsSchema.h` holds five pages -- map options (trail, live share, arrival alert; ui-lvgl opens it from the map's tools), advert (auto-advert; in ui-lvgl's advert popup, the quick panel's Advert), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds, units) messages & contacts (DM resends, contact expiry, favourites first) and sound (volume, the sound for direct messages / channels / adverts, advert sound scope). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page (`schemaRows()` draws a page's rows into any container, a screen or a popup); ui-new puts a schema section's rows into its own accordion sections (`SCHEMA_DISPLAY`, `SCHEMA_SYSTEM`, … in `SettingsScreen.h`), next to the rows it still draws itself (radio, keyboard, home pages, quick messages). Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off), and ui-new's lock clock look (`lock_compact`, Big / Compact), which ui-lvgl leaves out of its rows. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, font card, counter reset), shown by ui-new's Home > Status screen (Mesh and System tabs) and ui-lvgl's Home > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (the built-in LiPo curve or the user's `batt_curve_mv`, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Home > Repeater use it, MyMesh does the relaying. Sound is `ui-core/SoundControl.h` (`soundctl::`: the On / Off / Auto mode over `buzzer_quiet` + `buzzer_auto`, the volume with its preview, the built-in sounds, the two user melodies as an editable note list) and `ui-core/SoundNotifier.h` (which sound a message / advert plays: global choice, per-contact / per-channel alert and melody overrides); the schema's Sound page holds the volume and the PLAYS FOR choices, the channel melody override is `chanctl::melody`. Both frontends drive a `genericBuzzer`: a PWM piezo on L1, the ES8311 codec on the L2 (`BUZZER_I2S`: an audio task synthesises the notes and counts samples, the board powers the amp through `buzzerAmpPower()`). Outgoing text is `ui-core/MessageText.h` (`msgtext::`: the ten quick messages, the placeholders on offer -- `{loc}`, `{time}`, `{batt}`, live sensor readings -- and expanding them at send time, keeping a reply's `@[name] ` prefix as typed); ui-new's Messages / keyboard and ui-lvgl's compose "+" use it. `UiCore::markAllRead()` clears every unread counter; `MyMesh::advertFlood()` (formerly sim-only) sends the self-advert through repeaters, `advert()` zero-hop. ui-lvgl's screens share their widgets from `UITask.cpp`: `segmented()` (a one-of-N button row; `radioview::rowSegmented` puts one at the right of a settings row), `textField()` (a one-line field padded to exactly one line), `listRow`, `switchRow`, `headerButton`, `centerRow()` (a full-width row of centred items), `pillButton()` (the small "All" / "★ Fav" pill). A popup that asks for one line of text is `UITask::navTextEntry(TextEntry{title, done_cb, ...})` (`NavMap.h`): title, optional start text / hint / password / accepted characters / digits-first keyboard / a byte cap (`onByteCapInsert`, for text stored in a fixed buffer), the field focused over the keyboard; the callback gets the keyboard's READY / CANCEL and reads `_nav_ta`. New text-entry popups use it rather than wiring field and keyboard by hand. Replaces the per-item code in `SettingsScreen`: diff --git a/docs/solo/developer/ui-framework.md b/docs/solo/developer/ui-framework.md index 15c27044..c393eb97 100644 --- a/docs/solo/developer/ui-framework.md +++ b/docs/solo/developer/ui-framework.md @@ -238,6 +238,8 @@ filled while selected), `drawDisclosure` (▸ / ▾ on an accordion section), `drawPageDots`, `drawLoadingDots` (the waiting animation), `drawSignalBars` (SNR as four bars). +`MessagesScreen`'s history lists draw each message through one `drawHeadedBubble()` (sender, a `BubbleMark` -- delivery glyph or hop count -- and age over the body; `expand` wraps the body, otherwise the selected row marquees): a new list of chat-like rows passes it the row's pieces instead of laying out its own bubble. + `FullscreenMsgView::wrapLines()` is a standalone pixel-accurate word-wrapper (O(n), variable-width-font aware) reusable by any multi-line layout; it writes into the shared `s_wrap_trans` / `s_wrap_lines` scratch (single-threaded render, diff --git a/examples/companion_radio/ui-lvgl/AdminScreen.h b/examples/companion_radio/ui-lvgl/AdminScreen.h index a2162568..92c0596a 100644 --- a/examples/companion_radio/ui-lvgl/AdminScreen.h +++ b/examples/companion_radio/ui-lvgl/AdminScreen.h @@ -291,17 +291,6 @@ static lv_obj_t* adminPopupButton(lv_obj_t* parent, const char* text, uint8_t ac return b; } -static lv_obj_t* adminRowBox(lv_obj_t* parent) { - lv_obj_t* r = lv_obj_create(parent); - styleSurface(r, theme::BG); - lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(r, theme::GAP, 0); - return r; -} - // A typed value arrived: its editor. void UITask::adminValuePopup() { using namespace adminview; @@ -317,7 +306,7 @@ void UITask::adminValuePopup() { lv_obj_t* panel = navPopupPanel(f->label, false); s_val_lbl = s_choice = nullptr; if (f->kind == admin::K_ONOFF) { - lv_obj_t* r = adminRowBox(panel); + lv_obj_t* r = centerRow(panel); label(r, "Off", THEME_FONT_BODY, theme::TEXT_MUTED); lv_obj_t* sw = lv_switch_create(r); lv_obj_set_size(sw, 56, 28); @@ -325,7 +314,7 @@ void UITask::adminValuePopup() { lv_obj_add_event_cb(sw, onAdminValue, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)V_TOGGLE); label(r, "On", THEME_FONT_BODY, theme::TEXT_MUTED); } else if (f->kind == admin::K_NUMBER) { - lv_obj_t* r = adminRowBox(panel); + lv_obj_t* r = centerRow(panel); adminPopupButton(r, LV_SYMBOL_MINUS, V_MINUS, false); s_val_lbl = label(r, "", THEME_FONT_LARGE, theme::TEXT); lv_obj_set_width(s_val_lbl, 80); @@ -342,7 +331,7 @@ void UITask::adminValuePopup() { if (f->isRadio()) { noteLabel(panel, "A mismatch cuts the node off."); } - lv_obj_t* r = adminRowBox(panel); + lv_obj_t* r = centerRow(panel); adminPopupButton(r, LV_SYMBOL_OK " Save", V_SAVE, true); } @@ -388,7 +377,7 @@ void UITask::adminReplyPopup(const char* text) { lv_obj_set_scrollbar_mode(box, LV_SCROLLBAR_MODE_ACTIVE); } noteLabel(box, text[0] ? text : "(empty)", THEME_FONT_BODY, theme::TEXT); - lv_obj_t* r = adminRowBox(panel); + lv_obj_t* r = centerRow(panel); adminPopupButton(r, "OK", adminview::V_CLOSE, false); } diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 0ebd33da..23be9298 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -248,17 +248,6 @@ static lv_obj_t* wheel(lv_obj_t* parent, uint8_t n, int sel, lv_event_cb_t cb, u static lv_obj_t* colon(lv_obj_t* parent) { return label(parent, ":", THEME_FONT_LARGE, theme::TEXT_MUTED); } -static lv_obj_t* row(lv_obj_t* parent) { - lv_obj_t* r = lv_obj_create(parent); - styleSurface(r, theme::BG); - lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(r, theme::GAP, 0); - return r; -} - } // namespace clockview static void onOpenClock(lv_event_t* e) { (void)e; s_ui->showClock(); } @@ -304,7 +293,7 @@ void UITask::buildClock() { s_big = s_go_lbl = s_alarm_sw = s_cal = nullptr; // Tabs - lv_obj_t* tabs = row(body); + lv_obj_t* tabs = centerRow(body); static const char* const NAMES[TAB_COUNT] = { "Alarm", "Timer", "Stopwatch", "Calendar" }; for (int t = 0; t < TAB_COUNT; t++) { lv_obj_t* b = lv_button_create(tabs); @@ -318,7 +307,7 @@ void UITask::buildClock() { } if (s_tab == TAB_ALARM) { - lv_obj_t* r = row(body); + lv_obj_t* r = centerRow(body); wheel(r, 24, _prefs->alarm_hour % 24, onAlarmWheel, 0); colon(r); wheel(r, 60, _prefs->alarm_min % 60, onAlarmWheel, 1); @@ -384,7 +373,7 @@ void UITask::buildClock() { lv_obj_set_style_text_align(s_big, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_pad_ver(s_big, 18, 0); } else { - lv_obj_t* r = row(body); + lv_obj_t* r = centerRow(body); wheel(r, 24, s_timer_h, onTimerWheel, 0); colon(r); wheel(r, 60, s_timer_m, onTimerWheel, 1); @@ -398,7 +387,7 @@ void UITask::buildClock() { lv_obj_set_style_pad_ver(s_big, 18, 0); } - lv_obj_t* acts = row(body); + lv_obj_t* acts = centerRow(body); lv_obj_t* go = lv_button_create(acts); lv_obj_set_size(go, 150, 40); lv_obj_set_style_shadow_width(go, 0, 0); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index ccf098e8..896d8cb2 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -573,6 +573,34 @@ static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) { return b; } +// A full-width row of centred items that grows with them (button rows in +// popups, the clock's tabs and actions). +static lv_obj_t* centerRow(lv_obj_t* parent) { + lv_obj_t* r = lv_obj_create(parent); + styleSurface(r, theme::BG); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, theme::GAP, 0); + return r; +} + +// A small pill button with a label, right-aligned in its parent row. +static lv_obj_t* pillButton(lv_obj_t* row, const char* text, uint32_t bg, lv_event_cb_t cb, uintptr_t user) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, LV_SIZE_CONTENT, 24); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0); + lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)user); + lv_obj_center(label(b, text, THEME_FONT_SMALL, theme::TEXT)); + return b; +} + // ── Settings kit ── // Every settings page is built from the same few pieces: group() -- a section // title and the card its rows sit on, hairlines between them -- and rows on @@ -3152,16 +3180,7 @@ static void chatSection(lv_obj_t* parent, const char* text, int fold, int unread lv_obj_align_to(n, t, LV_ALIGN_OUT_RIGHT_MID, 8, 0); } if (filter < 0 || folded) return; - lv_obj_t* b = lv_button_create(row); - lv_obj_set_size(b, LV_SIZE_CONTENT, 24); - lv_obj_set_style_pad_hor(b, 10, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(fav_only ? theme::ACCENT_DIM : theme::SURFACE), 0); - lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_add_event_cb(b, onChatFilter, LV_EVENT_CLICKED, (void*)(uintptr_t)filter); - lv_obj_center(label(b, fav_only ? UI_SYMBOL_STAR " Fav" : "All", THEME_FONT_SMALL, theme::TEXT)); + pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", fav_only ? theme::ACCENT_DIM : theme::SURFACE, onChatFilter, filter); } // Section title with an "All" / "★ Fav" pill on the right that flips the @@ -3172,16 +3191,7 @@ static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(row, LV_PCT(100), 26); lv_obj_align(label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_LEFT, 0, -2); - lv_obj_t* b = lv_button_create(row); - lv_obj_set_size(b, LV_SIZE_CONTENT, 24); - lv_obj_set_style_pad_hor(b, 10, 0); - lv_obj_set_style_pad_ver(b, 0, 0); - lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(fav_only ? theme::ACCENT_DIM : theme::SURFACE), 0); - lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0); - lv_obj_add_event_cb(b, onChatFilter, LV_EVENT_CLICKED, (void*)(uintptr_t)which); - lv_obj_center(label(b, fav_only ? UI_SYMBOL_STAR " Fav" : "All", THEME_FONT_SMALL, theme::TEXT)); + pillButton(row, fav_only ? UI_SYMBOL_STAR " Fav" : "All", fav_only ? theme::ACCENT_DIM : theme::SURFACE, onChatFilter, which); } void UITask::showChats() {