diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index 9b4640a9..aa4f6e25 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -90,11 +90,12 @@ void UITask::buildBot() { lv_obj_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL); lv_obj_t* list = scrollList(body); + lv_obj_t* g = group(list, nullptr); for (int i = 0; i < botcfg::rowCount(s_tab); i++) { const botcfg::Row& r = botcfg::row(s_tab, i); uint8_t* fl = botcfg::flag(p, r.kind); - lv_obj_t* row = settingRow(list, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr); + lv_obj_t* row = settingRow(g, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr); if (fl && r.kind == botcfg::DM_SCOPE) { static const char* SCOPE[] = { "All", "Fav", "" }; lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32); @@ -120,11 +121,12 @@ void UITask::buildBot() { else snprintf(val, sizeof(val), "%02d:00", botcfg::hour(p, r.kind)); lv_obj_t* v = label(row, val, THEME_FONT_BODY, theme::ACCENT); lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); - lv_obj_set_width(v, botcfg::isHour(r.kind) ? 80 : 190); // fixed: LONG_DOT needs a width + lv_obj_set_width(v, botcfg::isHour(r.kind) ? 60 : 150); // fixed: LONG_DOT needs a width lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); - lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i); } } diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h index d1cb9223..a854ed36 100644 --- a/examples/companion_radio/ui-lvgl/ChannelScreen.h +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -39,11 +39,7 @@ static void clearForm() { static void onChanAction(lv_event_t* e) { s_ui->channelAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onChanDropdown(lv_event_t* e) { s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e), - (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); -} -static void onChanSegment(lv_event_t* e) { // user data: C_NOTIF / C_MELODY - s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e), - (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); + choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onChanRowHold(lv_event_t* e) { lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens the channel @@ -79,22 +75,18 @@ void UITask::channelMenu(int idx) { snprintf(title, sizeof(title), "%s", ch.name); // as named: "#" marks a hashtag channel lv_obj_t* panel = navPopupPanel(title, false); - // Notifications: three segments in the row (a dropdown's list ran off the - // bottom of the screen from a popup this low). - static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; - static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; - lv_obj_t* row = settingRow(panel, "Alerts", NULL); - radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF); - row = settingRow(panel, "Sound", NULL); - radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY); + lv_obj_t* g = group(panel, nullptr); + choiceRow(g, "Alerts", nullptr, "Default\nMuted\nAlways", chanctl::notif(_prefs, idx), onChanDropdown, + (void*)(uintptr_t)C_NOTIF); + choiceRow(g, "Sound", nullptr, "Default\nMelody 1\nMelody 2", chanctl::melody(_prefs, idx), onChanDropdown, + (void*)(uintptr_t)C_MELODY); const ScopeList& sl = the_mesh.scopeList(); if (sl.count > 0) { // only once regions are set up (Settings in the app) - row = settingRow(panel, "Scope", "Region it's sent in"); int o = 0; for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++) o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i)); - lv_obj_t* dd = radioview::rowDropdown(row, s_scope_opts, chanctl::scope(_prefs, idx), 112, onChanDropdown, C_SCOPE); - lv_obj_set_style_max_height(lv_dropdown_get_list(dd), 100, 0); // stays on screen; scrolls if long + choiceRow(g, "Scope", "Region it's sent in", s_scope_opts, chanctl::scope(_prefs, idx), onChanDropdown, + (void*)(uintptr_t)C_SCOPE); } lv_obj_t* acts = lv_obj_create(panel); diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 52021144..dc8258ed 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -92,7 +92,7 @@ static void onAlarmSwitch(lv_event_t* e) { s_ui->setAlarm(2, lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0); } static void onAlarmRepeat(lv_event_t* e) { - s_ui->setAlarm(3, (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->setAlarm(3, choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onTimerRoller(lv_event_t* e) { int v = (int)lv_roller_get_selected((lv_obj_t*)lv_event_get_target(e)); @@ -160,17 +160,12 @@ void UITask::buildClock() { if (_prefs->alarm_on) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onAlarmSwitch, LV_EVENT_VALUE_CHANGED, NULL); label(col, "Repeat", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_obj_t* dd = lv_dropdown_create(col); char opts[48]; int o = 0; for (uint8_t i = 0; i < NodePrefs::ALARM_REPEAT_COUNT; i++) o += snprintf(opts + o, sizeof(opts) - o, "%s%s", i ? "\n" : "", i ? NodePrefs::alarmRepeatLabel(i) : "Once"); - lv_dropdown_set_options(dd, opts); - lv_dropdown_set_selected(dd, NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask)); + lv_obj_t* dd = choiceCreate(col, opts, NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask), "Repeat"); lv_obj_set_width(dd, 112); - lv_obj_t* list = lv_dropdown_get_list(dd); // opens upwards: tighter so all four fit under the status bar - lv_obj_set_style_pad_ver(list, 4, 0); - lv_obj_set_style_text_line_space(list, 12, 0); lv_obj_add_event_cb(dd, onAlarmRepeat, LV_EVENT_VALUE_CHANGED, NULL); bool synced = rtc_clock.getCurrentTime() > 1000000000UL; label(body, synced ? "Rings at this local time (Settings time zone)." : "The clock isn't set yet: the alarm waits for a time sync.", diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index cc61c6d6..9d66935a 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -52,10 +52,10 @@ static lv_obj_t* actionButton(lv_obj_t* acts, const char* text, lv_event_cb_t cb static void onConvAction(lv_event_t* e) { s_ui->conversationAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onConvMelody(lv_event_t* e) { - s_ui->conversationMelody((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); + s_ui->conversationMelody(choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onConvNotif(lv_event_t* e) { - s_ui->conversationNotif((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); + s_ui->conversationNotif(choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onMsgAction(lv_event_t* e) { s_ui->messageAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onRoomLoginKb(lv_event_t* e) { s_ui->roomLoginDone(lv_event_get_code(e) == LV_EVENT_READY); } @@ -161,13 +161,10 @@ void UITask::conversationMenu(const uint8_t* pub_key) { lv_obj_t* panel = navPopupPanel(ci.name, false); bool room = ci.type == ADV_TYPE_ROOM; - if (!room) { // alerts: three segments (a dropdown's list runs off screen this low) - static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; - lv_obj_t* row = settingRow(panel, "Alerts", NULL); - radioview::rowSegmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif, 0); - static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; - row = settingRow(panel, "Sound", NULL); - radioview::rowSegmented(row, MELODY, contactctl::melody(_prefs, ci.id.pub_key), 200, onConvMelody, 0); + if (!room) { + lv_obj_t* g = group(panel, nullptr); + choiceRow(g, "Alerts", nullptr, "Default\nMuted\nAlways", contactctl::notif(_prefs, ci.id.pub_key), onConvNotif); + choiceRow(g, "Sound", nullptr, "Default\nMelody 1\nMelody 2", contactctl::melody(_prefs, ci.id.pub_key), onConvMelody); } else { bool in = _core->rooms.isLoggedIn(ci.id.pub_key); lv_obj_t* st = label(panel, in ? LV_SYMBOL_OK " Logged in: you can post" diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index 17e188c3..c67fa5be 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -407,15 +407,12 @@ void UITask::pinRowRefresh() { static void onAccent(lv_event_t* e) { s_ui->setAccent((int)(uintptr_t)lv_event_get_user_data(e)); } // A row of colour swatches; the current one ringed. -void UITask::accentRow(lv_obj_t* body) { - lv_obj_t* card = lv_obj_create(body); - styleSurface(card, theme::SURFACE); - lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_radius(card, theme::RADIUS, 0); - lv_obj_set_style_pad_all(card, theme::PAD, 0); - lv_obj_set_style_pad_row(card, 8, 0); +void UITask::accentRow(lv_obj_t* body) { // a row of a group: the label over the swatches + lv_obj_t* card = groupLine(body, false); lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(card, 8, 0); + lv_obj_set_style_pad_bottom(card, 10, 0); label(card, "Accent colour", THEME_FONT_BODY, theme::TEXT); lv_obj_t* row = lv_obj_create(card); lv_obj_remove_style_all(row); diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 1f56c18e..b7fa0f68 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -878,7 +878,7 @@ static void onLiveTiles(lv_event_t* e) { } static void onNavTool(lv_event_t* e) { s_ui->navToolAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onNavShareTarget(lv_event_t* e) { - s_ui->navSetShareTarget((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->navSetShareTarget(choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static lv_obj_t* toolRow(lv_obj_t* parent) { @@ -950,18 +950,7 @@ void UITask::navToolsPopup() { o += snprintf(opts + o, sizeof(opts) - o, "%s" UI_SYMBOL_STAR " %s", o ? "\n" : "", c.name); favs++; } - lv_obj_t* tr = lv_obj_create(list); - styleSurface(tr, theme::SURFACE); - lv_obj_remove_flag(tr, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(tr, LV_PCT(100), 40); - lv_obj_set_style_radius(tr, theme::RADIUS, 0); - lv_obj_align(label(tr, "Send to", THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0); - lv_obj_t* dd = lv_dropdown_create(tr); - lv_dropdown_set_options(dd, o ? opts : "(no channels)"); - lv_dropdown_set_selected(dd, sel); - lv_obj_set_width(dd, 170); - lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(dd, onNavShareTarget, LV_EVENT_VALUE_CHANGED, NULL); + choiceRow(group(list, nullptr), "Send to", nullptr, o ? opts : "(no channels)", sel, onNavShareTarget); r = toolRow(list); _nav_share_btn = toolButton(r, "", navmap::TL_SHARE_TOGGLE, true); toolButton(r, LV_SYMBOL_UPLOAD " Send once", navmap::TL_SHARE_ONCE, false); @@ -974,14 +963,12 @@ void UITask::navToolsPopup() { else snprintf(al, sizeof(al), "Off"); label(list, al, THEME_FONT_SMALL, theme::TEXT); - sectionTitle(list, "MAP"); - lv_obj_t* lsw = switchRow(list, "Live tiles", "Load missing tiles over WiFi", nullptr); + lv_obj_t* g = group(list, "MAP"); + lv_obj_t* lsw = switchRow(g, "Live tiles", "Load missing tiles over WiFi", nullptr); if (lvport::liveTiles()) lv_obj_add_state(lsw, LV_STATE_CHECKED); lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL); - r = toolRow(list); - toolButton(r, LV_SYMBOL_DOWNLOAD " Download this area", navmap::TL_DOWNLOAD, false); - r = toolRow(list); - toolButton(r, LV_SYMBOL_SETTINGS " Options: trail, sharing, alert", navmap::TL_OPTIONS, false); + listRow(g, "Download this area", "For use offline", onNavTool, (void*)(uintptr_t)navmap::TL_DOWNLOAD); + listRow(g, "Map options", "Trail, live sharing, arrival alert", onNavTool, (void*)(uintptr_t)navmap::TL_OPTIONS); _nav_reset_armed_ms = 0; refreshNavTools(); diff --git a/examples/companion_radio/ui-lvgl/OtaScreen.h b/examples/companion_radio/ui-lvgl/OtaScreen.h index 13f0caa7..636f8dfe 100644 --- a/examples/companion_radio/ui-lvgl/OtaScreen.h +++ b/examples/companion_radio/ui-lvgl/OtaScreen.h @@ -204,10 +204,8 @@ void UITask::buildOta() { _ota_btn_lbl = label(b, "", THEME_FONT_BODY, theme::TEXT); lv_obj_center(_ota_btn_lbl); stylePrimary(b); - lv_obj_t* h = label(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device " - "charged. Settings, contacts and messages are kept.", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP); - lv_obj_set_width(h, LV_PCT(100)); + groupNote(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device " + "charged. Settings, contacts and messages are kept."); refreshOta(); } diff --git a/examples/companion_radio/ui-lvgl/QuickScreen.h b/examples/companion_radio/ui-lvgl/QuickScreen.h index fbb58fb7..d9f4ca8b 100644 --- a/examples/companion_radio/ui-lvgl/QuickScreen.h +++ b/examples/companion_radio/ui-lvgl/QuickScreen.h @@ -107,17 +107,15 @@ void UITask::showQuickMsgs() { void UITask::buildQuickMsgs() { using namespace qview; lv_obj_t* body = newScreen("Quick messages", true); - lv_obj_t* t = label(body, "Sent from a chat's \"+\"; placeholders are filled in.", - THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); - lv_obj_set_width(t, LV_PCT(100)); + lv_obj_t* g = group(body, nullptr); for (int i = 0; i < msgtext::QUICK_COUNT; i++) { const char* q = msgtext::quick(_prefs, i); char title[msgtext::QUICK_LEN + 8]; snprintf(title, sizeof(title), "%d %s", i + 1, q[0] ? q : "(empty)"); - lv_obj_t* row = listRow(body, title, NULL, onQuickSlot, (void*)(uintptr_t)i); - if (!q[0]) lv_obj_set_style_text_color(lv_obj_get_child(row, 0), lv_color_hex(theme::TEXT_MUTED), 0); + lv_obj_t* row = listRow(g, title, NULL, onQuickSlot, (void*)(uintptr_t)i); + if (!q[0]) lv_obj_set_style_text_color(rowTitle(row), lv_color_hex(theme::TEXT_MUTED), 0); } + groupNote(body, "Sent from a chat's \"+\"; placeholders are filled in."); } // A field and the placeholders over the keyboard (a titled popup doesn't fit @@ -193,9 +191,9 @@ void UITask::advertPopup() { lv_obj_set_flex_grow(list, 1); lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(list, theme::GAP, 0); - sectionTitle(list, "SEND NOW"); - listRow(list, "Zero hop", "Direct range only", onAdvertGo, (void*)(uintptr_t)0); - listRow(list, "Flood", "Across the mesh", onAdvertGo, (void*)(uintptr_t)1); + lv_obj_t* g = group(list, "SEND NOW"); + listRow(g, "Zero hop", "Direct range only", onAdvertGo, (void*)(uintptr_t)0); + listRow(g, "Flood", "Across the mesh", onAdvertGo, (void*)(uintptr_t)1); schemaRows(list, settings::PG_ADVERT); } diff --git a/examples/companion_radio/ui-lvgl/RadioExtras.h b/examples/companion_radio/ui-lvgl/RadioExtras.h index 75fbbe66..406cf472 100644 --- a/examples/companion_radio/ui-lvgl/RadioExtras.h +++ b/examples/companion_radio/ui-lvgl/RadioExtras.h @@ -54,27 +54,25 @@ static void onScopeAction(lv_event_t* e) { s_ui->scopeAction((uint8_t)(uintptr_ // The Radio screen's lower sections, under the parameters. void UITask::buildRadioExtras(lv_obj_t* body) { NodePrefs* p = _prefs; - sectionTitle(body, "MY PRESETS"); + lv_obj_t* g = group(body, "MY PRESETS"); for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i]; if (!u.name[0]) continue; char sub[48]; snprintf(sub, sizeof(sub), "%.3f MHz SF%u BW%g 4/%u", u.freq, (unsigned)u.sf, (double)u.bw, (unsigned)u.cr); - lv_obj_t* row = listRow(body, u.name, sub, onPresetRow, (void*)(uintptr_t)i); - if (radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, u.freq, u.bw, u.sf, u.cr)) - lv_obj_align(label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + lv_obj_t* row = listRow(g, u.name, sub, onPresetRow, (void*)(uintptr_t)i); + if (radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, u.freq, u.bw, u.sf, u.cr)) rowCheck(row); } - listRow(body, LV_SYMBOL_PLUS " Save current settings", - radioctl::userPresetsFull(p) ? "All 4 used - a new name replaces the oldest" : "As a preset of your own", - onPresetSave, NULL); + actionRow(g, LV_SYMBOL_PLUS " Save current settings", onPresetSave, NULL, theme::ACCENT); + if (radioctl::userPresetsFull(p)) groupNote(body, "All 4 used - a new name replaces the oldest."); - sectionTitle(body, "SCOPE"); + g = group(body, "SCOPE"); const ScopeList& sl = the_mesh.scopeList(); char sub[48]; if (sl.count == 0) snprintf(sub, sizeof(sub), "None set up - messages go everywhere"); else snprintf(sub, sizeof(sub), "Default: %s (%u set up)", sl.default_idx ? sl.name(sl.default_idx) : "no scope", (unsigned)sl.count); - listRow(body, LV_SYMBOL_LIST " Scopes", sub, onOpenScopes, NULL); + listRow(g, "Scopes", sub, onOpenScopes, NULL); } // ── My presets ──────────────────────────────────────────────────────────────── @@ -184,14 +182,15 @@ void UITask::buildScopes() { lv_obj_t* body = newScreen("Scopes", true); const ScopeList& sl = the_mesh.scopeList(); if (_header && sl.count < ScopeList::MAX_SCOPE_ENTRIES) headerButton(_header, LV_SYMBOL_PLUS " Add", onScopeAdd, 4, NULL); - radiox::note(body, "A scope keeps messages within a region's repeaters. The default one is used for direct messages " - "and relaying; each channel picks its own in its options."); + lv_obj_t* g = group(body, nullptr); for (uint8_t i = 0; i <= sl.count; i++) { bool def = i == sl.default_idx; const char* sub = def ? "Default" : i == 0 ? "Reaches every repeater" : nullptr; - lv_obj_t* row = listRow(body, i == 0 ? "* (no scope)" : sl.name(i), sub, onScopeRow, (void*)(uintptr_t)i); - if (def) lv_obj_align(label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + lv_obj_t* row = listRow(g, i == 0 ? "* (no scope)" : sl.name(i), sub, onScopeRow, (void*)(uintptr_t)i); + if (def) rowCheck(row); } + groupNote(body, "A scope keeps messages within a region's repeaters. The default is used for direct messages " + "and relaying; each channel picks its own in its options."); } void UITask::scopeMenu(int idx) { diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index efe0da68..a83ca0fb 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -23,16 +23,39 @@ static bool s_freq_rpt = false; // the entry is for the repeater profile static const int OPTS_LEN = 1024; static char* s_opts = psramBuf(OPTS_LEN); // dropdown options (LVGL copies them) -// Label + optional hint on the left of a settings row; the control goes on the right. - +// A choice at the right of a settings row (its label titles the picker). static lv_obj_t* rowDropdown(lv_obj_t* row, const char* opts, int sel, int width, lv_event_cb_t cb, uintptr_t which) { - lv_obj_t* dd = lv_dropdown_create(row); - lv_dropdown_set_options(dd, opts); - if (sel >= 0) lv_dropdown_set_selected(dd, sel); - lv_obj_set_width(dd, width); - lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(dd, cb, LV_EVENT_VALUE_CHANGED, (void*)which); - return dd; + (void)width; + lv_obj_t* t = rowTitle(row); + lv_obj_t* c = choiceCreate(row, opts, sel, t ? lv_label_get_text(t) : ""); + lv_obj_align(c, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(c, cb, LV_EVENT_VALUE_CHANGED, (void*)which); + return c; +} + +// The LoRa parameters, as Radio and the repeater's own profile both set them: +// preset (0 = "Custom"), frequency (typed in a popup), SF, bandwidth, coding +// rate. `ids` are the radioSet / repeaterSet codes for preset, SF, BW, CR. +static void paramRows(lv_obj_t* g, const NodePrefs* p, int preset, float freq, uint8_t sf_v, float bw_v, uint8_t cr_v, + lv_event_cb_t cb, const uint8_t ids[4], lv_event_cb_t freq_cb, const char* sf_hint) { + int o = snprintf(s_opts, OPTS_LEN, "Custom"); + const char* name; float f, b; uint8_t sf, cr; + for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < OPTS_LEN - 24; i++) + o += snprintf(s_opts + o, OPTS_LEN - o, "\n%s", name); + rowDropdown(settingRow(g, "Preset", nullptr), s_opts, preset + 1, 0, cb, ids[0]); + char fs[16]; + snprintf(fs, sizeof(fs), "%.3f MHz", freq); + rowValue(settingRow(g, "Frequency", nullptr), fs, freq_cb); + o = 0; + for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n%d" : "%d", v); + rowDropdown(settingRow(g, "Spreading factor", sf_hint), s_opts, sf_v >= 5 && sf_v <= 12 ? sf_v - 5 : 0, 0, cb, ids[1]); + o = 0; + for (int i = 0; i < LORA_BW_OPT_COUNT; i++) + o += snprintf(s_opts + o, OPTS_LEN - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]); + rowDropdown(settingRow(g, "Bandwidth", nullptr), s_opts, nearestBwIndex(bw_v), 0, cb, ids[2]); + o = 0; + for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n4/%d" : "4/%d", v); + rowDropdown(settingRow(g, "Coding rate", nullptr), s_opts, cr_v >= 5 && cr_v <= 8 ? cr_v - 5 : 0, 0, cb, ids[3]); } // A few choices side by side at the right of a row (a dropdown's list can run @@ -47,7 +70,7 @@ static lv_obj_t* rowSegmented(lv_obj_t* row, const char** map, int sel, int widt } // namespace radioview static void onRadioDropdown(lv_event_t* e) { - s_ui->radioSet((int)(uintptr_t)lv_event_get_user_data(e), (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->radioSet((int)(uintptr_t)lv_event_get_user_data(e), choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onRadioSwitch(lv_event_t* e) { s_ui->radioSet((int)(uintptr_t)lv_event_get_user_data(e), @@ -68,60 +91,23 @@ void UITask::buildRadio() { s_overlay = s_ta = nullptr; NodePrefs* p = _prefs; - // Preset: "Custom" first (current params match none), then the list. - int o = snprintf(s_opts, OPTS_LEN, "Custom"); - const char* name; float f, b; uint8_t sf, cr; - for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < OPTS_LEN - 24; i++) - o += snprintf(s_opts + o, OPTS_LEN - o, "\n%s", name); - lv_obj_t* row = settingRow(body, "Preset", nullptr); - lv_obj_t* dd = rowDropdown(row, s_opts, radioctl::currentPreset(p) + 1, 200, onRadioDropdown, R_PRESET); - lv_dropdown_set_dir(dd, LV_DIR_BOTTOM); + lv_obj_t* g = group(body, "LORA"); + static const uint8_t IDS[4] = { R_PRESET, R_SF, R_BW, R_CR }; + paramRows(g, p, radioctl::currentPreset(p), p->freq, p->sf, p->bw, p->cr, onRadioDropdown, IDS, onRadioFreq, + "Higher = longer range"); + groupNote(body, "Everyone you talk to needs the same settings."); - // Frequency: tap to type it. - row = settingRow(body, "Frequency", "MHz"); - lv_obj_t* fb = lv_button_create(row); - lv_obj_set_size(fb, 120, 34); - lv_obj_align(fb, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_set_style_shadow_width(fb, 0, 0); - lv_obj_set_style_radius(fb, theme::RADIUS, 0); - lv_obj_set_style_bg_color(fb, lv_color_hex(theme::SURFACE_2), 0); - lv_obj_add_event_cb(fb, onRadioFreq, LV_EVENT_CLICKED, NULL); - char fs[16]; - snprintf(fs, sizeof(fs), "%.3f", p->freq); - lv_obj_center(label(fb, fs, THEME_FONT_BODY, theme::TEXT)); - - o = 0; - for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n%d" : "%d", v); - rowDropdown(settingRow(body, "Spreading factor", "Higher = longer range"), s_opts, - p->sf >= 5 && p->sf <= 12 ? p->sf - 5 : 0, 90, onRadioDropdown, R_SF); - o = 0; - for (int i = 0; i < LORA_BW_OPT_COUNT; i++) - o += snprintf(s_opts + o, OPTS_LEN - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]); - rowDropdown(settingRow(body, "Bandwidth", nullptr), s_opts, nearestBwIndex(p->bw), 130, onRadioDropdown, R_BW); - o = 0; - for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n4/%d" : "4/%d", v); - rowDropdown(settingRow(body, "Coding rate", nullptr), s_opts, p->cr >= 5 && p->cr <= 8 ? p->cr - 5 : 0, 90, - onRadioDropdown, R_CR); - - sectionTitle(body, "TRANSMIT"); - o = 0; + g = group(body, "TRANSMIT"); + int o = 0; for (int v = TX_MIN; v <= TX_MAX; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > TX_MIN ? "\n%d dBm" : "%d dBm", v); int tx = p->tx_power_dbm < TX_MIN ? TX_MIN : p->tx_power_dbm > TX_MAX ? TX_MAX : p->tx_power_dbm; - rowDropdown(settingRow(body, "TX power", p->tx_apc ? "Ceiling for auto power" : nullptr), s_opts, tx - TX_MIN, 110, + rowDropdown(settingRow(g, "TX power", p->tx_apc ? "Ceiling for auto power" : nullptr), s_opts, tx - TX_MIN, 0, onRadioDropdown, R_TX); - row = settingRow(body, "Auto power", p->client_repeat ? "Off while repeating" : "Lowers power on good links"); - lv_obj_t* sw = lv_switch_create(row); - lv_obj_set_size(sw, 46, 24); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + lv_obj_t* sw = switchRow(g, "Auto power", p->client_repeat ? "Off while repeating" : "Lowers power on good links", nullptr); if (p->tx_apc) lv_obj_add_state(sw, LV_STATE_CHECKED); if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_DISABLED); lv_obj_add_event_cb(sw, onRadioSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)R_APC); - lv_obj_t* note = label(body, "Everyone you talk to needs the same settings.", - THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); - lv_obj_set_width(note, LV_PCT(100)); - buildRadioExtras(body); // my presets, scopes (RadioExtras.h) } diff --git a/examples/companion_radio/ui-lvgl/RepeaterScreen.h b/examples/companion_radio/ui-lvgl/RepeaterScreen.h index 8a13b3c9..91f0a774 100644 --- a/examples/companion_radio/ui-lvgl/RepeaterScreen.h +++ b/examples/companion_radio/ui-lvgl/RepeaterScreen.h @@ -19,7 +19,7 @@ static lv_obj_t* s_scopes_sub = nullptr; // "Extra scopes" row's count, update static void onOpenRepeater(lv_event_t* e) { (void)e; s_ui->showRepeater(); } static void onRptDropdown(lv_event_t* e) { - s_ui->repeaterSet((int)(uintptr_t)lv_event_get_user_data(e), (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->repeaterSet((int)(uintptr_t)lv_event_get_user_data(e), choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onRptSwitch(lv_event_t* e) { s_ui->repeaterSet((int)(uintptr_t)lv_event_get_user_data(e), @@ -52,87 +52,50 @@ void UITask::buildRepeater() { lv_obj_t* body = newScreen("Repeater", true); NodePrefs* p = _prefs; - lv_obj_t* row = settingRow(body, "Repeater", p->client_repeat ? "Relaying for others" : "Relay others' messages"); - lv_obj_t* sw = lv_switch_create(row); - lv_obj_set_size(sw, 46, 24); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + lv_obj_t* g = group(body, nullptr); + lv_obj_t* sw = switchRow(g, "Repeater", p->client_repeat ? "Relaying for others" : "Relay others' messages", nullptr); if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onRptSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)RP_ON); - lv_obj_t* t = label(body, "Uses more battery; auto power pauses.", - THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); - lv_obj_set_width(t, LV_PCT(100)); + groupNote(body, "Uses more battery; auto power pauses."); - sectionTitle(body, "NETWORK"); - row = settingRow(body, "Relay on", p->repeater_use_profile ? "Its own frequency" : "Your chat frequency"); + g = group(body, "NETWORK"); + lv_obj_t* row = settingRow(g, "Relay on", p->repeater_use_profile ? "Its own frequency" : "Your chat frequency"); static const char* NET[] = { "Current", "Custom", "" }; - lv_obj_t* seg = segmented(row, NET, p->repeater_use_profile ? 1 : 0, 150, 34); - lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(seg, onRptNetwork, LV_EVENT_VALUE_CHANGED, NULL); + radioview::rowSegmented(row, NET, p->repeater_use_profile ? 1 : 0, 150, onRptNetwork, 0); if (p->repeater_use_profile) { - int o = snprintf(s_opts, OPTS_LEN, "Custom"); - const char* name; float f, b; uint8_t sf, cr; - for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < OPTS_LEN - 24; i++) - o += snprintf(s_opts + o, OPTS_LEN - o, "\n%s", name); - lv_obj_t* dd = rowDropdown(settingRow(body, "Preset", nullptr), s_opts, rptctl::currentPreset(p) + 1, 200, - onRptDropdown, RP_PRESET); - lv_dropdown_set_dir(dd, LV_DIR_BOTTOM); - - row = settingRow(body, "Frequency", "MHz"); - lv_obj_t* fb = lv_button_create(row); - lv_obj_set_size(fb, 120, 34); - lv_obj_align(fb, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_set_style_shadow_width(fb, 0, 0); - lv_obj_set_style_radius(fb, theme::RADIUS, 0); - lv_obj_set_style_bg_color(fb, lv_color_hex(theme::SURFACE_2), 0); - lv_obj_add_event_cb(fb, onRptFreq, LV_EVENT_CLICKED, NULL); - char fs[16]; - snprintf(fs, sizeof(fs), "%.3f", p->repeater_freq); - lv_obj_center(label(fb, fs, THEME_FONT_BODY, theme::TEXT)); - - o = 0; - for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n%d" : "%d", v); - rowDropdown(settingRow(body, "Spreading factor", nullptr), s_opts, - p->repeater_sf >= 5 && p->repeater_sf <= 12 ? p->repeater_sf - 5 : 0, 90, onRptDropdown, RP_SF); - o = 0; - for (int i = 0; i < LORA_BW_OPT_COUNT; i++) - o += snprintf(s_opts + o, OPTS_LEN - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]); - rowDropdown(settingRow(body, "Bandwidth", nullptr), s_opts, nearestBwIndex(p->repeater_bw), 130, onRptDropdown, RP_BW); - o = 0; - for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, OPTS_LEN - o, v > 5 ? "\n4/%d" : "4/%d", v); - rowDropdown(settingRow(body, "Coding rate", nullptr), s_opts, - p->repeater_cr >= 5 && p->repeater_cr <= 8 ? p->repeater_cr - 5 : 0, 90, onRptDropdown, RP_CR); + static const uint8_t IDS[4] = { RP_PRESET, RP_SF, RP_BW, RP_CR }; + radioview::paramRows(g, p, rptctl::currentPreset(p), p->repeater_freq, p->repeater_sf, p->repeater_bw, + p->repeater_cr, onRptDropdown, IDS, onRptFreq, nullptr); } - sectionTitle(body, "WHAT IT RELAYS"); - switchRow(body, "Skip adverts", "Relay messages, not adverts", &p->repeat_skip_adverts); + g = group(body, "WHAT IT RELAYS"); + switchRow(g, "Skip adverts", "Relay messages, not adverts", &p->repeat_skip_adverts); char v[12]; int o = 0; for (int i = 0; i <= rptctl::MAX_HOPS; i++) { rptctl::fmtHops(v, sizeof(v), (uint8_t)i); o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v); } - rowDropdown(settingRow(body, "Max hops", "Hop limit for floods"), s_opts, p->repeat_max_hops, 90, - onRptDropdown, RP_HOPS); + rowDropdown(settingRow(g, "Max hops", "Hop limit for floods"), s_opts, p->repeat_max_hops, 0, onRptDropdown, RP_HOPS); o = 0; for (int i = 0; i <= rptctl::MAX_YIELD; i++) { rptctl::fmtYield(v, sizeof(v), (uint8_t)i); o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v); } - rowDropdown(settingRow(body, "Yield", "Let fixed repeaters go first"), s_opts, p->repeat_delay_boost, 90, + rowDropdown(settingRow(g, "Yield", "Let fixed repeaters go first"), s_opts, p->repeat_delay_boost, 0, onRptDropdown, RP_YIELD); o = 0; for (int i = 0; i < rptctl::snrChoiceCount(); i++) { rptctl::fmtSnr(v, sizeof(v), rptctl::snrFromChoice(i)); o += snprintf(s_opts + o, OPTS_LEN - o, "%s%s", i ? "\n" : "", v); } - rowDropdown(settingRow(body, "Min SNR", "Ignore weaker packets"), s_opts, rptctl::snrToChoice(p->repeat_min_snr), 100, + rowDropdown(settingRow(g, "Min SNR", "Ignore weaker packets"), s_opts, rptctl::snrToChoice(p->repeat_min_snr), 0, onRptDropdown, RP_SNR); - switchRow(body, "Skip duplicates", "Skip if already relayed", &p->repeat_suppress_dup); - switchRow(body, "Scope only", "Only floods in your scopes", &p->repeat_scope_only); + switchRow(g, "Skip duplicates", "Skip if already relayed", &p->repeat_suppress_dup); + switchRow(g, "Scope only", "Only floods in your scopes", &p->repeat_scope_only); char sub[48]; extraScopesSummary(p, sub, sizeof(sub)); - s_scopes_sub = lv_obj_get_child(listRow(body, LV_SYMBOL_LIST " Extra scopes", sub, onRptScopes, NULL), 1); + s_scopes_sub = rowSub(listRow(g, "Extra scopes", sub, onRptScopes, NULL)); } // Rebuilds the screen where it was scrolled to. diff --git a/examples/companion_radio/ui-lvgl/SoundScreen.h b/examples/companion_radio/ui-lvgl/SoundScreen.h index 18ff583f..eb33704c 100644 --- a/examples/companion_radio/ui-lvgl/SoundScreen.h +++ b/examples/companion_radio/ui-lvgl/SoundScreen.h @@ -133,7 +133,7 @@ static void onMelMatrix(lv_event_t* e) { (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); } static void onMelBpm(lv_event_t* e) { - s_ui->melodySet(MS_BPM, (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->melodySet(MS_BPM, choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onMelAction(lv_event_t* e) { s_ui->melodyAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onMelPlay(lv_event_t* e) { (void)e; s_ui->melodyAction(MA_PLAY); } @@ -272,19 +272,16 @@ void UITask::buildSoundRows(lv_obj_t* body, bool top) { radioview::rowSegmented(row, MODES, soundctl::mode(_prefs), 150, onSoundMode, 0); return; } - sectionTitle(body, "MELODIES"); + lv_obj_t* g = group(body, "MELODIES"); for (int slot = 0; slot < 2; slot++) { soundctl::Melody m; soundctl::load(_prefs, slot, m); char sub[40]; if (m.len) snprintf(sub, sizeof(sub), "%d note%s, %u BPM", m.len, m.len == 1 ? "" : "s", soundctl::bpm(m.bpm_idx)); else snprintf(sub, sizeof(sub), "Empty - tap to compose"); - listRow(body, slot ? LV_SYMBOL_AUDIO " Melody 2" : LV_SYMBOL_AUDIO " Melody 1", sub, onMelodyRow, (void*)(uintptr_t)slot); + listRow(g, slot ? "Melody 2" : "Melody 1", sub, onMelodyRow, (void*)(uintptr_t)slot); } - lv_obj_t* t = label(body, "Per chat: hold it in Chats for its options.", - THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); - lv_obj_set_width(t, LV_PCT(100)); + groupNote(body, "Per chat: hold it in Chats for its options."); } // ── Melody editor ───────────────────────────────────────────────────────────── @@ -312,11 +309,8 @@ void UITask::buildMelodies() { lv_obj_t* top = flexRow(body); lv_obj_t* slots = segmented(top, SLOTS, s_slot, 150, 32); lv_obj_add_event_cb(slots, onMelSlot, LV_EVENT_VALUE_CHANGED, NULL); - s_bpm = lv_dropdown_create(top); - lv_dropdown_set_options_static(s_bpm, "60 BPM\n90 BPM\n120 BPM\n150 BPM\n180 BPM"); - lv_dropdown_set_selected(s_bpm, s_mel.bpm_idx); + s_bpm = choiceCreate(top, "60 BPM\n90 BPM\n120 BPM\n150 BPM\n180 BPM", s_mel.bpm_idx, "Tempo"); lv_obj_set_size(s_bpm, 102, 32); - lv_obj_set_style_pad_ver(s_bpm, 6, 0); lv_obj_add_event_cb(s_bpm, onMelBpm, LV_EVENT_VALUE_CHANGED, NULL); smallButton(top, LV_SYMBOL_PLUS, MA_ADD); // (fits: 150 + 102 + 40 + gaps) @@ -377,7 +371,7 @@ void UITask::melodySlot(int slot) { s_slot = slot == 1 ? 1 : 0; soundctl::load(_prefs, s_slot, s_mel); s_sel = 0; - if (s_bpm) lv_dropdown_set_selected(s_bpm, s_mel.bpm_idx); + if (s_bpm) choiceSetSelected(s_bpm, s_mel.bpm_idx); rebuildStrip(); syncControls(); refreshMelody(); diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index cf57bd29..206a3646 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -131,23 +131,19 @@ static uint64_t treeBytes(const char* root, int depth = 0) { return sum; } -// A bar with "x free of y" under it. -static lv_obj_t* usageBar(lv_obj_t* parent, lv_obj_t** lbl) { - lv_obj_t* card = lv_obj_create(parent); - styleSurface(card, theme::SURFACE); - lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_radius(card, theme::RADIUS, 0); - lv_obj_set_style_pad_all(card, theme::PAD, 0); - lv_obj_set_style_pad_row(card, 6, 0); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - lv_obj_t* bar = lv_bar_create(card); +// A group row: a bar with "x used of y" under it. +static lv_obj_t* usageBar(lv_obj_t* g, lv_obj_t** lbl) { + lv_obj_t* row = infoLine(g); + lv_obj_set_style_pad_ver(row, 10, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(row, 6, 0); + lv_obj_t* bar = lv_bar_create(row); lv_obj_set_size(bar, LV_PCT(100), 10); lv_bar_set_range(bar, 0, 1000); lv_obj_set_style_bg_color(bar, lv_color_hex(theme::BG), LV_PART_MAIN); lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(bar, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR); - *lbl = label(card, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + *lbl = label(row, "", THEME_FONT_SMALL, theme::TEXT_MUTED); return bar; } @@ -181,11 +177,11 @@ static void refreshSizes() { static void onOpenStorage(lv_event_t* e) { (void)e; s_ui->showStorage(); } static void onHistKeep(lv_event_t* e) { - s_ui->storageKeep((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->storageKeep((int)choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onHistClear(lv_event_t* e) { (void)e; s_ui->storageClearHistory(); } static void onLiveCap(lv_event_t* e) { - s_ui->storageLiveCap((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + s_ui->storageLiveCap((int)choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onLiveClear(lv_event_t* e) { (void)e; s_ui->storageClearLive(); } @@ -201,22 +197,21 @@ void UITask::buildStorage() { memset(s_cat_val, 0, sizeof(s_cat_val)); s_clear_armed_ms = 0; - sectionTitle(body, "SD CARD"); + lv_obj_t* g = group(body, "SD CARD"); if (!lvport::mountStorage()) { - label(body, "No SD card.", THEME_FONT_BODY, theme::TEXT_MUTED); + infoRow(g, "Card", "None", theme::TEXT_MUTED); } else { - s_sd_bar = usageBar(body, &s_sd_lbl); + s_sd_bar = usageBar(g, &s_sd_lbl); lv_label_set_text(s_sd_lbl, "Reading the card..."); - lv_obj_t* cats = infoCard(body); // what takes the space - for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = infoRow(cats, CAT_NAME[c], "...", theme::ACCENT); - s_status = infoRow(cats, "Files", ""); + for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = infoRow(g, CAT_NAME[c], "...", theme::ACCENT); // what takes the space + s_status = infoRow(g, "Files", ""); s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment startWalk(); } - sectionTitle(body, "DEVICE"); + g = group(body, "DEVICE"); lv_obj_t* flbl; - lv_obj_t* fbar = usageBar(body, &flbl); + lv_obj_t* fbar = usageBar(g, &flbl); uint64_t ft = 0, fu = 0; if (lvport::flashInfo(ft, fu)) { if (fu == 0) fu = treeBytes(lvport::FLASH_ROOT); // the sim only counts files @@ -224,37 +219,25 @@ void UITask::buildStorage() { } else { lv_label_set_text(flbl, "Not available"); } - label(body, "Contacts, channels, settings, saved trail, waypoints.", THEME_FONT_SMALL, theme::TEXT_MUTED); + groupNote(body, "Contacts, channels, settings, saved trail, waypoints."); - sectionTitle(body, "MESSAGES"); + g = group(body, "MESSAGES"); int keep_idx = histstore::KEEP_DEFAULT; for (int i = 0; i < histstore::KEEP_COUNT; i++) if (histstore::KEEP[i] == s_archive.keep()) keep_idx = i; - lv_obj_t* dd = dropdownRow(body, "Kept per chat", histstore::KEEP_OPTS, keep_idx); - lv_obj_remove_event_cb(dd, onKeyboardAlphabet); - lv_obj_add_event_cb(dd, onHistKeep, LV_EVENT_VALUE_CHANGED, NULL); - char note[96]; - snprintf(note, sizeof(note), "Each channel and contact keeps its newest ones on the card (%u KB per 100).", + char hint[48]; + snprintf(hint, sizeof(hint), "Newest on the card, %u KB per 100", (unsigned)((sizeof(ChHistEntry) * 100 + 512) / 1024)); - lv_obj_t* n = label(body, note, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(n, LV_LABEL_LONG_WRAP); - lv_obj_set_width(n, LV_PCT(100)); - lv_obj_t* row = listRow(body, LV_SYMBOL_TRASH " Delete message history", "Every chat, on the card and in memory", - onHistClear, NULL); - s_clear_lbl = lv_obj_get_child(row, 0); + choiceRow(g, "Kept per chat", hint, histstore::KEEP_OPTS, keep_idx, onHistKeep); + s_clear_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete message history", onHistClear, NULL, theme::FAIL); - sectionTitle(body, "LIVE MAP TILES"); + g = group(body, "LIVE MAP TILES"); int cap_idx = mapview::LIVE_CAP_DEFAULT; for (int i = 0; i < mapview::LIVE_CAP_COUNT; i++) if (((uint64_t)mapview::LIVE_CAP_MB[i] << 20) == mapview::s_live_cache.limit()) cap_idx = i; - lv_obj_t* cd = dropdownRow(body, "Keep up to", mapview::LIVE_CAP_OPTS, cap_idx); - lv_obj_remove_event_cb(cd, onKeyboardAlphabet); - lv_obj_add_event_cb(cd, onLiveCap, LV_EVENT_VALUE_CHANGED, NULL); - s_live_info = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(s_live_info, LV_LABEL_LONG_WRAP); - lv_obj_set_width(s_live_info, LV_PCT(100)); - lv_obj_t* lrow = listRow(body, LV_SYMBOL_TRASH " Delete live map tiles", "Downloaded maps stay", - onLiveClear, NULL); - s_live_lbl = lv_obj_get_child(lrow, 0); + s_live_info = infoRow(g, "Fetched while browsing", ""); + choiceRow(g, "Keep up to", "Past it, the oldest go first", mapview::LIVE_CAP_OPTS, cap_idx, onLiveCap); + s_live_lbl = actionRow(g, LV_SYMBOL_TRASH " Delete live map tiles", onLiveClear, NULL, theme::FAIL); + groupNote(body, "Downloaded areas stay."); s_live_armed_ms = 0; s_shown_ms = 0; } @@ -289,8 +272,7 @@ void UITask::pollStorage() { char b[16], t[96]; fmtBytes(b, sizeof(b), mapview::s_live_cache.bytes()); if (mapview::s_live_cache.clearing()) snprintf(t, sizeof(t), "Deleting... %lu left", (unsigned long)mapview::s_live_cache.count()); - else snprintf(t, sizeof(t), "Fetched while browsing online: %lu tiles, %s. Past the limit, the oldest go first.", - (unsigned long)mapview::s_live_cache.count(), b); + else snprintf(t, sizeof(t), "%lu tiles, %s", (unsigned long)mapview::s_live_cache.count(), b); lv_label_set_text(s_live_info, t); } if (s_live_armed_ms && millis() - s_live_armed_ms > 4000) { diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 58535adb..f2677eeb 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -150,24 +150,6 @@ static lv_obj_t* dimOverlay(lv_obj_t* parent) { return o; } -// A settings row: the label left (a muted hint under it, cut at `hint_w`), -// room on the right for a switch / dropdown / slider. -static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) { - lv_obj_t* row = lv_obj_create(parent); - styleSurface(row, theme::SURFACE); - lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); - lv_obj_set_style_radius(row, theme::RADIUS, 0); - lv_obj_align(label(row, text, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13); - if (hint) { - lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED); - lv_label_set_long_mode(h, LV_LABEL_LONG_DOT); - lv_obj_set_size(h, hint_w, 15); // fixed height: cut, don't wrap onto the label - lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); - } - return row; -} - // ── Info cards ── // Label / value rows on a card (Diagnostics, GPS, node detail, About): the // label small and muted on the left, the value on the right -- wrapping, @@ -214,6 +196,7 @@ static lv_obj_t* infoLine(lv_obj_t* card) { // one row's box, the hairline abo lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_ver(r, 5, 0); + if (lv_obj_has_flag(card, LV_OBJ_FLAG_USER_1)) lv_obj_set_style_pad_hor(r, theme::PAD, 0); // in a group() if (lv_obj_get_index(r) > 0) { lv_obj_set_style_border_side(r, LV_BORDER_SIDE_TOP, 0); lv_obj_set_style_border_width(r, 1, 0); @@ -252,6 +235,8 @@ static lv_obj_t* infoNote(lv_obj_t* card, const char* key, const char* text) { return t; } +static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text); + static void infoSet(lv_obj_t* value, const char* text) { lv_obj_t* row = lv_obj_get_parent(value); if (!text || !text[0]) { lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); return; } @@ -269,6 +254,322 @@ static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) { 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 +// it: settingRow() (label, hint under it, a control on the right: switch, +// choice, slider, segmented), listRow() (opens something: a chevron), +// actionRow() (does something now) and groupNote() (one line under a card). +// Rows built outside a group keep the older look: a card of their own. + +static inline bool isGroup(lv_obj_t* o) { return o && lv_obj_has_flag(o, LV_OBJ_FLAG_USER_1); } + +static lv_obj_t* group(lv_obj_t* parent, const char* title) { + if (title) sectionTitle(parent, title); + lv_obj_t* c = infoCard(parent); + lv_obj_set_style_pad_hor(c, 0, 0); // rows pad themselves: a pressed row lights edge to edge + lv_obj_set_style_clip_corner(c, true, 0); + lv_obj_add_flag(c, LV_OBJ_FLAG_USER_1); + return c; +} + +static lv_obj_t* groupNote(lv_obj_t* parent, const char* text) { + lv_obj_t* l = label(parent, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_obj_set_style_pad_hor(l, theme::PAD, 0); + return l; +} + +// A group row's box: full width, at least a touch target tall, a flex row +// with the text column growing and whatever follows it on the right. +static lv_obj_t* groupLine(lv_obj_t* card, bool tappable) { + lv_obj_t* r = infoLine(card); + lv_obj_set_style_pad_hor(r, theme::PAD, 0); + lv_obj_set_style_pad_ver(r, 6, 0); + lv_obj_set_style_min_height(r, theme::ROW_H, 0); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, 8, 0); + if (tappable) { + lv_obj_add_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(r, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(r, LV_OPA_COVER, LV_STATE_PRESSED); + } + return r; +} + +// The label (and the hint under it, wrapping) filling a group row's left side. +static lv_obj_t* groupText(lv_obj_t* row, const char* text, const char* hint, uint32_t col = theme::TEXT) { + lv_obj_t* t = flexBox(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(t, 1); + lv_obj_set_flex_grow(t, 1); + lv_obj_set_style_pad_row(t, 1, 0); + // The row's touch-target height lives here: a row's own min height comes + // after its flex layout, which then sits everything at the top. + lv_obj_set_style_min_height(t, theme::ROW_H - 12, 0); + lv_obj_set_flex_align(t, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_t* l = label(t, text, THEME_FONT_BODY, col); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + if (hint) { + lv_obj_t* h = label(t, hint, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP); + lv_obj_set_width(h, LV_PCT(100)); + } + lv_obj_set_user_data(row, l); // rowTitle() + return l; +} + +// The title label of a settingRow() / listRow(), to recolour or rename it. +static lv_obj_t* rowTitle(lv_obj_t* row) { return (lv_obj_t*)lv_obj_get_user_data(row); } + +// A settings row: the label left (a muted hint under it, cut at `hint_w`), +// room on the right for a switch / dropdown / slider. +static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) { + if (isGroup(parent)) { + lv_obj_t* row = groupLine(parent, false); + groupText(row, text, hint); + return row; + } + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::SURFACE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); + lv_obj_set_style_radius(row, theme::RADIUS, 0); + lv_obj_t* t = label(row, text, THEME_FONT_BODY, theme::TEXT); + lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13); + lv_obj_set_user_data(row, t); + if (hint) { + lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(h, LV_LABEL_LONG_DOT); + lv_obj_set_size(h, hint_w, 15); // fixed height: cut, don't wrap onto the label + lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5); + } + return row; +} + +// Does something at once (Reboot, Delete ...): its text in `col`, no chevron. +// Returns the label, for a confirm-by-second-tap text. +static lv_obj_t* actionRow(lv_obj_t* card, const char* text, lv_event_cb_t cb, void* user, + uint32_t col = theme::TEXT) { + lv_obj_t* r = groupLine(card, true); + lv_obj_t* l = groupText(r, text, nullptr, col); + lv_obj_add_event_cb(r, cb, LV_EVENT_CLICKED, user); + return l; +} + +// ── Choice: a value picked from a list ── +// In place of a dropdown: the value and a chevron; a tap opens the options +// over the screen, and a pick sends LV_EVENT_VALUE_CHANGED like a dropdown. +struct Choice { + char* opts; // "\n"-separated, owned + uint16_t sel, count; + char title[32]; + lv_obj_t* value; +}; +static lv_obj_t* s_pick_overlay = nullptr; +static lv_obj_t* s_pick_target = nullptr; + +static Choice* choiceOf(lv_obj_t* c) { return (Choice*)lv_obj_get_user_data(c); } + +// Option `i` of a "\n"-list, into buf. +static const char* choiceItem(const Choice* ch, int i, char* buf, size_t n) { + const char* p = ch->opts; + for (int k = 0; k < i && p; k++) { p = strchr(p, '\n'); if (p) p++; } + if (!p) { buf[0] = '\0'; return buf; } + const char* e = strchr(p, '\n'); + size_t len = e ? (size_t)(e - p) : strlen(p); + if (len >= n) len = n - 1; + memcpy(buf, p, len); + buf[len] = '\0'; + return buf; +} + +static void choiceShow(lv_obj_t* c) { + Choice* ch = choiceOf(c); + char v[48]; + lv_label_set_text(ch->value, choiceItem(ch, ch->sel, v, sizeof(v))); +} + +static void choiceSetOptions(lv_obj_t* c, const char* opts) { + Choice* ch = choiceOf(c); + lv_free(ch->opts); + size_t n = strlen(opts); + ch->opts = (char*)lv_malloc(n + 1); + memcpy(ch->opts, opts, n + 1); + ch->count = 1; + for (const char* p = opts; *p; p++) if (*p == '\n') ch->count++; + if (ch->sel >= ch->count) ch->sel = 0; + choiceShow(c); +} + +static int choiceSelected(lv_obj_t* c) { return choiceOf(c)->sel; } +static void choiceSetSelected(lv_obj_t* c, int i) { + Choice* ch = choiceOf(c); + if (i < 0 || i >= ch->count) return; + ch->sel = (uint16_t)i; + choiceShow(c); +} + +static void pickerClose() { + if (s_pick_overlay) lv_obj_delete_async(s_pick_overlay); + s_pick_overlay = s_pick_target = nullptr; +} + +static void onPickOption(lv_event_t* e) { + lv_obj_t* c = s_pick_target; + pickerClose(); + if (!c) return; + choiceSetSelected(c, (int)(uintptr_t)lv_event_get_user_data(e)); + lv_obj_send_event(c, LV_EVENT_VALUE_CHANGED, NULL); // may rebuild the screen: c is gone after this +} + +static void pickerOpen(lv_obj_t* c) { + Choice* ch = choiceOf(c); + pickerClose(); + s_pick_target = c; + s_pick_overlay = dimOverlay(lv_layer_top()); + lv_obj_add_event_cb(s_pick_overlay, [](lv_event_t* e) { // a tap beside the list: nothing changes + if (lv_event_get_target(e) == lv_event_get_current_target(e)) pickerClose(); + }, LV_EVENT_CLICKED, NULL); + lv_obj_t* panel = lv_obj_create(s_pick_overlay); + styleSurface(panel, theme::BG); + lv_obj_set_width(panel, lv_display_get_horizontal_resolution(NULL) - 48); + lv_obj_set_height(panel, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(panel, lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 16, 0); + lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2); + lv_obj_set_style_radius(panel, theme::RADIUS, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_pad_all(panel, 6, 0); + lv_obj_set_style_pad_row(panel, 4, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE); + lv_obj_t* t = label(panel, ch->title, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_style_pad_hor(t, 4, 0); + lv_obj_t* card = group(panel, nullptr); + lv_obj_t* sel_row = nullptr; + for (int i = 0; i < ch->count; i++) { + char v[48]; + bool cur = i == ch->sel; + lv_obj_t* r = groupLine(card, true); + lv_obj_set_style_min_height(r, 38, 0); + groupText(r, choiceItem(ch, i, v, sizeof(v)), nullptr, cur ? theme::ACCENT : theme::TEXT); + if (cur) { label(r, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); sel_row = r; } + lv_obj_add_event_cb(r, onPickOption, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + } + anim::popup(s_pick_overlay); + if (sel_row) { lv_obj_update_layout(panel); lv_obj_scroll_to_view_recursive(sel_row, LV_ANIM_OFF); } +} + +// A choice: in a group row the value is bare text (accent) and a chevron; +// elsewhere it sits on a small surface of its own. +static lv_obj_t* choiceCreate(lv_obj_t* parent, const char* opts, int sel, const char* title) { + lv_obj_t* c = lv_obj_create(parent); + lv_obj_remove_style_all(c); + lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(c, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // END + SIZE_CONTENT mis-sizes + lv_obj_set_style_pad_column(c, 6, 0); + lv_obj_set_ext_click_area(c, 10); + lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); + if (!isGroup(lv_obj_get_parent(parent))) { + lv_obj_set_style_bg_color(c, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); + lv_obj_set_style_radius(c, theme::RADIUS_SM, 0); + lv_obj_set_style_pad_hor(c, 10, 0); + lv_obj_set_style_pad_ver(c, 8, 0); + } + Choice* ch = (Choice*)lv_malloc(sizeof(Choice)); + memset(ch, 0, sizeof(*ch)); + snprintf(ch->title, sizeof(ch->title), "%s", title ? title : ""); + ch->value = label(c, "", THEME_FONT_BODY, theme::ACCENT); + lv_obj_set_style_max_width(ch->value, 140, 0); // a long value wraps (LONG_DOT mis-sizes with SIZE_CONTENT) + lv_obj_set_style_text_align(ch->value, LV_TEXT_ALIGN_RIGHT, 0); + label(c, LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_user_data(c, ch); + ch->sel = sel < 0 ? 0 : (uint16_t)sel; + choiceSetOptions(c, opts); + lv_obj_add_event_cb(c, [](lv_event_t* e) { pickerOpen((lv_obj_t*)lv_event_get_current_target(e)); }, + LV_EVENT_CLICKED, NULL); + lv_obj_add_event_cb(c, [](lv_event_t* e) { + lv_obj_t* o = (lv_obj_t*)lv_event_get_current_target(e); + if (s_pick_target == o) pickerClose(); + Choice* ch = choiceOf(o); + lv_free(ch->opts); + lv_free(ch); + }, LV_EVENT_DELETE, NULL); + return c; +} + + +// One settings row: label left, a choice right. +static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint, const char* options, int sel, + lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* row = settingRow(parent, text, hint); + lv_obj_t* c = choiceCreate(row, options, sel, text); + lv_obj_align(c, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(c, cb, LV_EVENT_VALUE_CHANGED, user); + return c; +} + +// A value typed in a popup (frequency, name ...): shown as a choice is, with +// a pencil; the tap is the caller's. +static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* c = flexBox(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(c, 6, 0); + lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(c, 10); + lv_obj_set_style_opa(c, LV_OPA_60, LV_STATE_PRESSED); + label(c, text, THEME_FONT_BODY, theme::ACCENT); + label(c, LV_SYMBOL_EDIT, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_add_event_cb(c, cb, LV_EVENT_CLICKED, user); + return c; +} + +// A tick on a listRow() (the one in use), left of its chevron. +static void rowCheck(lv_obj_t* row) { + lv_obj_t* l = label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT); + if (isGroup(lv_obj_get_parent(row))) lv_obj_move_to_index(l, (int32_t)lv_obj_get_child_count(row) - 2); + else lv_obj_align(l, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); +} + +// The subtitle label of a listRow() / settingRow() with one, to update it. +static lv_obj_t* rowSub(lv_obj_t* row) { + if (isGroup(lv_obj_get_parent(row))) return lv_obj_get_child(lv_obj_get_child(row, 0), 1); + return lv_obj_get_child(row, 1); +} + +// Settings pages: the schema's, then this UI's own. Display, Power and Time +// split the schema's "Display & power" page (shared with L1, left as it is) +// into what their titles say. +enum : uint8_t { PG_KEYBOARD = settings::PG_COUNT, PG_ABOUT, PG_DISPLAY, PG_POWER, PG_TIME, PG_ALL }; + +// A schema setting by its NodePrefs field; -1 if there's none. +static int settingIdx(uint16_t offset) { + for (int i = 0; i < settings::COUNT; i++) if (settings::ALL[i].offset == offset) return i; + return -1; +} +#define SETTING(field) settingIdx(offsetof(NodePrefs, field)) + +// A setting's current value as its choice shows it ("UTC+2", "3.4 V" ...). +static const char* settingText(const NodePrefs& p, int idx, char* buf, int n) { + buf[0] = '\0'; + if (idx < 0) return buf; + const settings::Setting& st = settings::ALL[idx]; + uint8_t v = settings::get(p, st); + if (st.option) st.option(v, buf, n, p); + else snprintf(buf, n, "%s", v ? "On" : "Off"); + return buf; +} +static lv_obj_t* s_sec_card[settings::SEC_COUNT]; // schemaRows()' groups, by section +static bool s_opts_from_map = false; // Map options opened from the map: back returns there +static int32_t s_settings_y = 0; // Settings' scroll, kept while a page of it is open + // A one-of-N choice: a row (or grid, with "\n" in the map) of checkable // buttons, `sel` checked (-1: none). `item` is the unchecked button colour — // SURFACE on a SURFACE_2 panel, SURFACE_2 on the page. @@ -929,6 +1230,8 @@ static void onBack(lv_event_t* e) { (void)e; s_ui->back(); } lv_obj_t* UITask::newScreen(const char* title, bool with_back) { _home_clock = _home_date = _home_unread = nullptr; _thread_list = _compose_ta = _keyboard = nullptr; + if (_shown_screen == SCR_SETTINGS && _body) s_settings_y = lv_obj_get_scroll_y(_body); + pickerClose(); _header = _body = nullptr; _nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr; _node_info = _node_ping = _node_delete_lbl = nullptr; @@ -1022,7 +1325,7 @@ void UITask::back() { case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: // the map's options go back to the map - if (_settings_page == settings::PG_NAV) openMap(true); else showSettings(); + if (_settings_page == settings::PG_NAV && s_opts_from_map) openMap(true); else showSettings(); break; case SCR_QUICK: // back to Messages & contacts' bottom, where the row is if (_nav_overlay) { navClosePopup(); break; } @@ -1322,8 +1625,16 @@ static void onChatFilter(lv_event_t* e); enum : uint8_t { CF_CHANNELS, CF_ROOMS, CF_CONTACTS }; // favourites-only filters // One tappable list row: title, optional muted subtitle, optional badge. +// In a group(): a flat row, the subtitle wrapping, a chevron on the right. static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub, lv_event_cb_t cb, void* user) { + if (isGroup(parent)) { + lv_obj_t* row = groupLine(parent, true); + groupText(row, title, sub); + label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); + return row; + } lv_obj_t* row = lv_button_create(parent); lv_obj_set_size(row, LV_PCT(100), theme::ROW_H); lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE), 0); @@ -1336,6 +1647,7 @@ static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub, lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); lv_obj_set_width(t, 230); lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13); + lv_obj_set_user_data(row, t); if (sub) { lv_obj_t* s = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED); lv_label_set_long_mode(s, LV_LABEL_LONG_DOT); @@ -1754,7 +2066,7 @@ void UITask::refreshNearbyList() { snprintf(sub, sizeof(sub), "%s%s%s%s", kind, e.is_live ? " - live" : "", (e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : ""); lv_obj_t* row = listRow(_nearby_list, title, sub, onNearbyRow, (void*)(uintptr_t)i); - if (e.fav) lv_obj_set_style_text_color(lv_obj_get_child(row, 0), lv_color_hex(theme::ACCENT), 0); + if (e.fav) lv_obj_set_style_text_color(rowTitle(row), lv_color_hex(theme::ACCENT), 0); if (right[0]) { lv_obj_t* r = label(row, right, THEME_FONT_SMALL, e.is_live ? theme::OK : theme::TEXT_MUTED); lv_obj_align(r, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); @@ -2531,19 +2843,7 @@ static lv_obj_t* s_kb_alt_dd = nullptr; static void onKeyboardAlphabet(lv_event_t* e) { (void)e; - s_ui->setKeyboardAlphabets(lv_dropdown_get_selected(s_kb_main_dd), lv_dropdown_get_selected(s_kb_alt_dd)); -} - -// One settings row: label left, dropdown right. -static lv_obj_t* dropdownRow(lv_obj_t* parent, const char* text, const char* options, int sel) { - lv_obj_t* row = settingRow(parent, text, NULL); - lv_obj_t* dd = lv_dropdown_create(row); - lv_dropdown_set_options_static(dd, options); - lv_dropdown_set_selected(dd, sel); - lv_obj_set_width(dd, 130); - lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(dd, onKeyboardAlphabet, LV_EVENT_VALUE_CHANGED, NULL); - return dd; + s_ui->setKeyboardAlphabets(choiceSelected(s_kb_main_dd), choiceSelected(s_kb_alt_dd)); } // A preference toggle (0/1 byte in NodePrefs), saved on change. @@ -2578,7 +2878,7 @@ static void onSchemaSwitch(lv_event_t* e) { } static void onSchemaDropdown(lv_event_t* e) { s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e), - (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e))); + choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onOpenSchemaPage(lv_event_t* e) { s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); } static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); } @@ -2590,9 +2890,11 @@ static void onVolumeSlider(lv_event_t* e) { s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e))); } + void UITask::showSchemaSettings(int page) { + if (page == settings::PG_NAV && !_nav_back) s_opts_from_map = _screen == SCR_MAP; _screen = SCR_SETTINGS_NAV; - _settings_page = (uint8_t)(page < settings::PG_COUNT ? page : 0); + _settings_page = (uint8_t)(page < PG_ALL ? page : 0); buildSchemaSettings(); } @@ -2646,108 +2948,155 @@ void UITask::setTapWake(bool on) { } void UITask::buildSchemaSettings() { - lv_obj_t* body = newScreen(settings::pageTitle(_settings_page), true); + static const char* const OWN[] = { "Keyboard", "About", "Display", "Power", "Time" }; + const char* title = _settings_page >= PG_KEYBOARD ? OWN[_settings_page - PG_KEYBOARD] : settings::pageTitle(_settings_page); + lv_obj_t* body = newScreen(title, true); _prune_lbl = nullptr; _prune_armed_ms = 0; + switch (_settings_page) { + case PG_KEYBOARD: buildKeyboardPage(body); return; + case PG_ABOUT: buildAboutPage(body); return; + case PG_DISPLAY: { + lv_obj_t* g = group(body, "SCREEN"); + schemaRow(g, SETTING(display_brightness)); + schemaRow(g, SETTING(auto_off_secs)); + schemaRow(g, SETTING(msg_wake_screen_off)); + lv_obj_t* tw = switchRow(g, "Tap to wake", "Off: only the top button wakes it", nullptr); // in NVS + if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); + lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); + g = group(body, "LOCK"); + schemaRow(g, SETTING(auto_lock)); + listRow(g, "Screen PIN", _pin[0] ? "On - asked when the screen wakes" : "Off", onPinSetup, NULL); + accentRow(group(body, "LOOK")); // DeviceScreen.h + return; + } + case PG_POWER: { + lv_obj_t* g = group(body, "BATTERY"); + schemaRow(g, SETTING(batt_display_mode)); + schemaRow(g, SETTING(low_batt_mv)); + g = group(body, "GPS"); + schemaRow(g, SETTING(gps_interval)); + return; + } + case PG_TIME: { + lv_obj_t* g = group(body, nullptr); + for (int i = 0; i < settings::COUNT; i++) if (settings::ALL[i].section == settings::SEC_TIME) schemaRow(g, i); + return; + } + } schemaRows(body, _settings_page); - if (_settings_page == settings::PG_MESSAGES) { // the action that goes with "Contact expiry" - lv_obj_t* b = lv_button_create(body); - lv_obj_set_size(b, LV_PCT(100), 38); - lv_obj_set_style_shadow_width(b, 0, 0); - lv_obj_set_style_radius(b, theme::RADIUS, 0); - lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); - lv_obj_add_event_cb(b, onPruneContacts, LV_EVENT_CLICKED, NULL); - _prune_lbl = label(b, LV_SYMBOL_TRASH " Remove inactive contacts now", THEME_FONT_BODY, theme::TEXT); - lv_obj_center(_prune_lbl); - } - if (_settings_page == settings::PG_DEVICE) { // after Lock screen's section, in NVS not the schema - sectionTitle(body, "WAKE"); - lv_obj_t* tw = switchRow(body, "Tap to wake", "Off: only the top button turns it on", nullptr); - if (_tap_wake) lv_obj_add_state(tw, LV_STATE_CHECKED); - lv_obj_add_event_cb(tw, onTapWake, LV_EVENT_VALUE_CHANGED, NULL); - sectionTitle(body, "SECURITY"); - listRow(body, LV_SYMBOL_EYE_CLOSE " Screen PIN", _pin[0] ? "On - asked when the screen wakes" : "Off", - onPinSetup, NULL); - sectionTitle(body, "LOOK"); - accentRow(body); // DeviceScreen.h - } if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies if (_settings_page == settings::PG_MESSAGES) { - sectionTitle(body, "QUICK MESSAGES"); + lv_obj_t* c = s_sec_card[settings::SEC_CONTACTS]; // the action that goes with "Contact expiry" + if (c) _prune_lbl = actionRow(c, LV_SYMBOL_TRASH " Remove inactive contacts now", onPruneContacts, NULL, theme::FAIL); char sub[48]; snprintf(sub, sizeof(sub), "%d of %d set - sent with one tap", msgtext::quickUsed(_prefs), msgtext::QUICK_COUNT); - listRow(body, LV_SYMBOL_EDIT " Quick messages", sub, onOpenQuickMsgs, NULL); + listRow(s_sec_card[settings::SEC_MESSAGES], "Quick messages", sub, onOpenQuickMsgs, NULL); } } -// A page's schema rows under their section titles (Settings pages and the tools' options). +// Settings > Keyboard: the two scripts the keyboard switches between. +void UITask::buildKeyboardPage(lv_obj_t* body) { + uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; + uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0; + if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0; + if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a; + lv_obj_t* g = group(body, "SCRIPTS"); + // Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek). + s_kb_main_dd = choiceRow(g, "Main", nullptr, "Latin\nCyrillic\nGreek", main_a, onKeyboardAlphabet); + // "None" = no second script (stored as alt == main, as ui-new does). + s_kb_alt_dd = choiceRow(g, "Additional", "The globe key switches to it", "None\nLatin\nCyrillic\nGreek", + alt_a == main_a ? 0 : alt_a + 1, onKeyboardAlphabet); + groupNote(body, "Hold a letter for accents and other variants."); +} + +// Settings > About: this node, the firmware, credits. +void UITask::buildAboutPage(lv_obj_t* body) { + lv_obj_t* about = infoCard(body); + infoRow(about, "Node", the_mesh.getNodeName()); + infoRow(about, "Firmware", FIRMWARE_VERSION); + if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); + sectionTitle(body, "CREDITS"); + lv_obj_t* cr = infoCard(body); + infoNote(cr, "Map data", creditText((lvport::mountStorage() && mapview::s_provider->available()) + ? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors")); + infoNote(cr, "Emoji", creditText("Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC-BY\xC2\xA0" "4.0)")); +} + +// A page's schema rows, one group per section (Settings pages and the tools' +// options). Each section's card is kept in s_sec_card for rows added after. + void UITask::schemaRows(lv_obj_t* body, uint8_t page) { uint8_t sec = 0xFF; + lv_obj_t* card = nullptr; + for (lv_obj_t*& c : s_sec_card) c = nullptr; for (int i = 0; i < settings::COUNT; i++) { const settings::Setting& st = settings::ALL[i]; if (settings::sectionPage(st.section) != page) continue; if (st.section != sec) { sec = st.section; - sectionTitle(body, settings::sectionTitle(sec)); - if (sec == settings::SEC_SOUND) buildSoundRows(body, true); // On / Off / Auto + card = s_sec_card[sec] = group(body, settings::sectionTitle(sec)); + if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto } - uint8_t v = settings::get(*_prefs, st); - if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release - lv_obj_t* row = settingRow(body, st.label, NULL); - lv_obj_t* sl = lv_slider_create(row); - lv_slider_set_range(sl, 0, 4); - lv_slider_set_value(sl, v, LV_ANIM_OFF); - lv_obj_set_size(sl, 170, 10); - lv_obj_align(sl, LV_ALIGN_RIGHT_MID, -18, 0); - lv_obj_set_ext_click_area(sl, 14); - lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL); - continue; - } - if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps - lv_obj_t* row = settingRow(body, st.label, NULL); - lv_obj_t* sl = lv_slider_create(row); - lv_slider_set_range(sl, 5, 100); - uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct - : (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5); - lv_slider_set_value(sl, pct, LV_ANIM_OFF); - lv_obj_set_size(sl, 170, 10); - lv_obj_align(sl, LV_ALIGN_RIGHT_MID, -18, 0); - lv_obj_set_ext_click_area(sl, 14); - lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL); - lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL); - continue; - } - if (!st.option) { - lv_obj_t* sw = switchRow(body, st.label, st.hint, nullptr); - if (v) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); - continue; - } - char opts[160]; - int o = 0; - for (uint8_t k = 0; k < st.count && o < (int)sizeof(opts) - 16; k++) { - if (k) opts[o++] = '\n'; - st.option(k, opts + o, sizeof(opts) - o, *_prefs); - o += strlen(opts + o); - } - opts[o] = '\0'; - lv_obj_t* row = settingRow(body, st.label, st.hint, 176); - lv_obj_t* dd = lv_dropdown_create(row); - lv_dropdown_set_options(dd, opts); - lv_dropdown_set_selected(dd, v); - lv_obj_set_width(dd, 112); - lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0); - lv_obj_add_event_cb(dd, onSchemaDropdown, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); + schemaRow(card, i); } } +// One schema setting as a row of `card`: a switch, a choice, or (brightness, +// volume) a slider. +void UITask::schemaRow(lv_obj_t* card, int i) { + if (i < 0 || i >= settings::COUNT) return; + const settings::Setting& st = settings::ALL[i]; + uint8_t v = settings::get(*_prefs, st); + if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release + lv_obj_t* row = settingRow(card, st.label, NULL); + lv_obj_t* sl = lv_slider_create(row); + lv_slider_set_range(sl, 0, 4); + lv_slider_set_value(sl, v, LV_ANIM_OFF); + lv_obj_set_size(sl, 150, 8); + lv_obj_set_style_margin_right(sl, 8, 0); + lv_obj_set_ext_click_area(sl, 14); + lv_obj_add_event_cb(sl, onVolumeSlider, LV_EVENT_RELEASED, NULL); + return; + } + if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps + lv_obj_t* row = settingRow(card, st.label, NULL); + lv_obj_t* sl = lv_slider_create(row); + lv_slider_set_range(sl, 5, 100); + uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct + : (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5); + lv_slider_set_value(sl, pct, LV_ANIM_OFF); + lv_obj_set_size(sl, 150, 8); + lv_obj_set_style_margin_right(sl, 8, 0); + lv_obj_set_ext_click_area(sl, 14); + lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL); + return; + } + if (!st.option) { + lv_obj_t* sw = switchRow(card, st.label, st.hint, nullptr); + if (v) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); + return; + } + char opts[160]; + int o = 0; + for (uint8_t k = 0; k < st.count && o < (int)sizeof(opts) - 16; k++) { + if (k) opts[o++] = '\n'; + st.option(k, opts + o, sizeof(opts) - o, *_prefs); + o += strlen(opts + o); + } + opts[o] = '\0'; + choiceRow(card, st.label, st.hint, opts, v, onSchemaDropdown, (void*)(uintptr_t)i); +} + void UITask::setSchemaValue(int idx, int v) { if (!_prefs || idx < 0 || idx >= settings::COUNT) return; const settings::Setting& st = settings::ALL[idx]; settings::set(*_prefs, st, (uint8_t)v); if (st.changed) st.changed(*_core); the_mesh.savePrefs(); - if (st.offset == offsetof(NodePrefs, units_imperial)) { // other labels depend on it + if (st.offset == offsetof(NodePrefs, units_imperial) && _screen == SCR_SETTINGS_NAV) { // other labels depend on it lv_obj_t* body = _body; int32_t y = body ? lv_obj_get_scroll_y(body) : 0; buildSchemaSettings(); @@ -2763,68 +3112,77 @@ void UITask::showSettings() { static void onOpenRadio(lv_event_t* e); // RadioScreen.h static void onOpenStorage(lv_event_t* e); // StorageScreen.h +// Settings: groups of rows, each opening a page (chevron) or switching +// something on the spot. What the app is for first, the system last. void UITask::buildSettings() { - // The order of the original (ui-new): display, sound, radio, system, - // keyboard, contacts and messages. Tools have their own Home tiles. + bool restore = _nav_back; // back from one of its pages: where it was lv_obj_t* body = newScreen("Settings", true); + char sub[48]; + lv_obj_t* g; + char v1[24], v2[24]; if (_prefs) { - sectionTitle(body, "DISPLAY"); - listRow(body, LV_SYMBOL_EYE_OPEN " Display & power", "Screen, battery, time, units", - onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE); - sectionTitle(body, "SOUND"); - char sub[48], vol[12]; + g = group(body, "DEVICE"); + listRow(g, LV_SYMBOL_EYE_OPEN " Display", "Brightness, screen off, lock, colour", + onOpenSchemaPage, (void*)(uintptr_t)PG_DISPLAY); + snprintf(sub, sizeof(sub), "Shutdown %s - GPS: %s", settingText(*_prefs, SETTING(low_batt_mv), v1, sizeof(v1)), + settingText(*_prefs, SETTING(gps_interval), v2, sizeof(v2))); + listRow(g, LV_SYMBOL_BATTERY_FULL " Power", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_POWER); + char vol[12]; settings::optVolume(_prefs->buzzer_volume, vol, sizeof(vol), *_prefs); - snprintf(sub, sizeof(sub), "%s, %s - alerts, melodies", soundctl::modeLabel(soundctl::mode(_prefs)), vol); - listRow(body, LV_SYMBOL_VOLUME_MAX " Sound", sub, onOpenSchemaPage, (void*)(uintptr_t)settings::PG_SOUND); - sectionTitle(body, "RADIO"); + snprintf(sub, sizeof(sub), "%s, %s", soundctl::modeLabel(soundctl::mode(_prefs)), vol); + listRow(g, LV_SYMBOL_VOLUME_MAX " Sound", sub, onOpenSchemaPage, (void*)(uintptr_t)settings::PG_SOUND); + static const char* const SCRIPT[] = { "Latin", "Cyrillic", "Greek" }; + uint8_t ma = _prefs->keyboard_main_alphabet % NodePrefs::KB_ALPHABET_COUNT; + uint8_t aa = _prefs->keyboard_alt_alphabet % NodePrefs::KB_ALPHABET_COUNT; + snprintf(sub, sizeof(sub), aa == ma ? "%s" : "%s + %s", SCRIPT[ma], SCRIPT[aa]); + listRow(g, LV_SYMBOL_KEYBOARD " Keyboard", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_KEYBOARD); + listRow(g, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, expiry, quick messages", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); + } + + g = group(body, "CONNECTIONS"); + if (_prefs) { int pi = radioctl::currentPreset(_prefs); const char* pn = "Custom"; float f, b; uint8_t sf, cr; if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr); snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm); - listRow(body, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); - } else { - sectionTitle(body, "RADIO"); + listRow(g, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL); } - bluetoothRow(body); - wifiRow(body); - sectionTitle(body, "SYSTEM"); - listRow(body, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); - listRow(body, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); - if (_core->gpsAvailable()) { - lv_obj_t* sw = switchRow(body, "GPS", "For maps and sharing", nullptr); + bluetoothRow(g); + wifiRow(g); + if (_core->gpsAvailable()) { // as WiFi: the switch turns it on / off, the row opens its details + lv_obj_t* sw = switchRow(g, LV_SYMBOL_GPS " GPS", "Tap for satellites and signal", nullptr); if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); - listRow(body, LV_SYMBOL_GPS " GPS details", "Satellites, signal, sky view", onOpenGpsFromSettings, NULL); + lv_obj_t* row = lv_obj_get_parent(sw); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); + lv_obj_add_event_cb(row, [](lv_event_t* e) { + if (lv_event_get_target(e) == lv_event_get_current_target(e)) onOpenGpsFromSettings(e); // not the switch + }, LV_EVENT_CLICKED, NULL); } - listRow(body, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); - listRow(body, LV_SYMBOL_REFRESH " Reboot", NULL, onPowerRow, (void*)(uintptr_t)1); - listRow(body, LV_SYMBOL_POWER " Power off", NULL, onPowerRow, (void*)(uintptr_t)0); - sectionTitle(body, "KEYBOARD"); - uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; - uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0; - if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0; - if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a; - // Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek). - s_kb_main_dd = dropdownRow(body, "Main", "Latin\nCyrillic\nGreek", main_a); - // "None" = no second script (stored as alt == main, as ui-new does). - s_kb_alt_dd = dropdownRow(body, "Additional", "None\nLatin\nCyrillic\nGreek", - alt_a == main_a ? 0 : alt_a + 1); - label(body, "Hold a letter for accents and other variants.", THEME_FONT_SMALL, theme::TEXT_MUTED); + g = group(body, "MAP & DATA"); + if (_prefs) listRow(g, UI_SYMBOL_MAP " Map", "Trail, live sharing, arrival alert", + onOpenSchemaPage, (void*)(uintptr_t)settings::PG_NAV); + listRow(g, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); + + g = group(body, "SYSTEM"); + listRow(g, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); if (_prefs) { - sectionTitle(body, "CONTACTS & MESSAGES"); - listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, expiry, quick messages", - onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES); + snprintf(sub, sizeof(sub), "%s, %s", settingText(*_prefs, SETTING(tz_offset_hours), v1, sizeof(v1)), + _prefs->clock_12h ? "12 h" : "24 h"); + listRow(g, UI_SYMBOL_CLOCK " Time", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_TIME); + schemaRow(g, SETTING(units_imperial)); } + listRow(g, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL); + listRow(g, LV_SYMBOL_LIST " About", "Node, firmware, credits", onOpenSchemaPage, (void*)(uintptr_t)PG_ABOUT); - sectionTitle(body, "ABOUT"); - lv_obj_t* about = infoCard(body); - infoRow(about, "Node", the_mesh.getNodeName()); - infoRow(about, "Firmware", FIRMWARE_VERSION); - if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) infoRow(about, "Built", FIRMWARE_BUILD_DATE); - infoNote(about, "Map data", creditText((lvport::mountStorage() && mapview::s_provider->available()) - ? mapview::s_provider->attribution() : "\xC2\xA9 OpenStreetMap contributors")); - infoNote(about, "Emoji", creditText("Twemoji \xC2\xA9 Twitter, Inc. and contributors (CC-BY\xC2\xA0" "4.0)")); + g = group(body, nullptr); + actionRow(g, LV_SYMBOL_REFRESH " Reboot", onPowerRow, (void*)(uintptr_t)1); + actionRow(g, LV_SYMBOL_POWER " Power off", onPowerRow, (void*)(uintptr_t)0, theme::FAIL); + if (restore) { lv_obj_update_layout(body); lv_obj_scroll_to_y(body, s_settings_y, LV_ANIM_OFF); } } void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { @@ -2860,5 +3218,25 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) { { "quick", &UITask::showQuickMsgs }, }; for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; } + if (!strncmp(name, "page", 4)) { s_ui->showSchemaSettings(atoi(name + 4)); return; } + if (!strcmp(name, "map")) { s_ui->openMap(true); return; } + if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; } + if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; } +} +// Scrolls the screen's (or a popup's) main list by dy; returns what was left to scroll. +static void dbgScrollable(lv_obj_t* o, lv_obj_t*& best, int& h) { + if (lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN) || lv_obj_check_type(o, &lv_roller_class) || lv_obj_check_type(o, &lv_dropdownlist_class)) return; + int v = lv_obj_get_scroll_bottom(o) + lv_obj_get_scroll_y(o); + if (lv_obj_has_flag(o, LV_OBJ_FLAG_SCROLLABLE) && v > h) { h = v; best = o; } + for (uint32_t i = 0; i < lv_obj_get_child_count(o); i++) dbgScrollable(lv_obj_get_child(o, i), best, h); +} +extern "C" EMSCRIPTEN_KEEPALIVE int sim_scroll(int dy) { + lv_obj_t* o = nullptr; int h = 10; + dbgScrollable(lv_layer_top(), o, h); + if (!o) dbgScrollable(lv_screen_active(), o, h); + if (!o) return -1; + int left = lv_obj_get_scroll_bottom(o); + lv_obj_scroll_by(o, 0, -(dy < left ? dy : left), LV_ANIM_OFF); + return left; } #endif diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 761df951..8ab3b66d 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -271,6 +271,9 @@ private: void buildSettings(); void buildSchemaSettings(); void schemaRows(lv_obj_t* body, uint8_t page); + void schemaRow(lv_obj_t* card, int idx); + void buildKeyboardPage(lv_obj_t* body); + void buildAboutPage(lv_obj_t* body); void buildAdminPick(); void buildOta(); void refreshOta(); diff --git a/examples/companion_radio/ui-lvgl/WifiScreen.h b/examples/companion_radio/ui-lvgl/WifiScreen.h index 8a27ea36..72f4bfe6 100644 --- a/examples/companion_radio/ui-lvgl/WifiScreen.h +++ b/examples/companion_radio/ui-lvgl/WifiScreen.h @@ -128,6 +128,8 @@ static void wifiRow(lv_obj_t* body) { lv_obj_add_event_cb(sw, onWifiAllowed, LV_EVENT_VALUE_CHANGED, NULL); lv_obj_t* row = lv_obj_get_parent(sw); lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED); lv_obj_add_event_cb(row, [](lv_event_t* e) { if (lv_event_get_target(e) == lv_event_get_current_target(e)) s_ui->showWifi(); // not the switch }, LV_EVENT_CLICKED, NULL);