diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index a161e0d3..16e513a0 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -203,6 +203,9 @@ static void saveLiveCap(int idx) { nvs::putI8("mc_ui", "ltcap", idx); } // (off: only the top button does). static bool loadTapWake() { return nvs::getBool("mc_ui", "tapwake", true); } static void saveTapWake(bool on) { nvs::putBool("mc_ui", "tapwake", on); } +// Messages: which sections are folded (bit per section). +static int loadChatFold() { return nvs::getU8("mc_ui", "chfold", 0); } +static void saveChatFold(int bits) { nvs::putU8("mc_ui", "chfold", bits); } // Filesystem size and space in use (Settings > Storage), plus the card's own // size -- a card whose FAT partition is small (e.g. written by a Raspberry Pi // imager) shows both. The first free-space count on a big card takes a moment. @@ -497,6 +500,9 @@ static void saveLiveCap(int idx) { s_live_cap = idx; } static bool s_tap_wake = true; static bool loadTapWake() { return s_tap_wake; } static void saveTapWake(bool on) { s_tap_wake = on; } +static int s_chat_fold = 0; +static int loadChatFold() { return s_chat_fold; } +static void saveChatFold(int bits) { s_chat_fold = bits; } // The browser has no card: a nominal 32 GB, used = what the files add up to // (the storage screen counts them anyway; it passes that in). static bool sdInfo(uint64_t& total, uint64_t& used, uint64_t& card) { total = card = 32ULL << 30; used = 0; return true; } diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index 206a3646..be581f15 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -13,6 +13,9 @@ namespace storeview { enum Cat : uint8_t { C_MAPS, C_LIVE, C_MSGS, C_TRAILS, C_OTHER, C_COUNT }; static const char* const CAT_NAME[C_COUNT] = { "Maps", "Live map tiles", "Messages", "Trails (GPX)", "Other" }; +// Each category's colour in the card's bar and on its row; fixed, whatever the accent. +static const uint32_t CAT_COL[C_COUNT] = { 0x4C8DF6, 0x3CC6D8, 0xE8A33C, 0x6FCF6F, 0xB08AE0 }; +static const uint32_t UNCOUNTED_COL = 0x5A5750; // in use, not (yet) in a category: the walk, the filesystem static uint64_t s_bytes[C_COUNT]; static uint32_t s_files = 0; @@ -28,7 +31,8 @@ static char s_path[192]; static bool s_sd_read = false, s_sd_ok = false; static uint64_t s_sd_total = 0, s_sd_used = 0, s_sd_card = 0; -static lv_obj_t* s_sd_bar = nullptr; +static lv_obj_t* s_sd_bar = nullptr; // the card: a bar split by category +static lv_obj_t* s_seg[C_COUNT + 1]; // its segments, the last one uncounted static lv_obj_t* s_sd_lbl = nullptr; static lv_obj_t* s_cat_val[C_COUNT]; static lv_obj_t* s_status = nullptr; @@ -147,10 +151,70 @@ static lv_obj_t* usageBar(lv_obj_t* g, lv_obj_t** lbl) { return bar; } +// The card's bar: a track with one segment per category, side by side. +static lv_obj_t* segmentBar(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_obj_create(row); + lv_obj_remove_style_all(bar); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(bar, LV_PCT(100), 10); + lv_obj_set_style_bg_color(bar, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_radius(bar, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_clip_corner(bar, true, 0); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + for (int c = 0; c <= C_COUNT; c++) { + lv_obj_t* sg = lv_obj_create(bar); + lv_obj_remove_style_all(sg); + lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(sg, 0, LV_PCT(100)); + lv_obj_set_style_bg_color(sg, lv_color_hex(c < C_COUNT ? CAT_COL[c] : UNCOUNTED_COL), 0); + lv_obj_set_style_bg_opa(sg, LV_OPA_COVER, 0); + s_seg[c] = sg; + } + *lbl = label(row, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + return bar; +} + +// A category row: its colour dot, name, size in its colour. +static lv_obj_t* catRow(lv_obj_t* g, int c) { + lv_obj_t* v = infoRow(g, CAT_NAME[c], "...", CAT_COL[c]); + lv_obj_t* d = lv_obj_create(lv_obj_get_parent(v)); + lv_obj_remove_style_all(d); + lv_obj_set_size(d, 10, 10); + lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(d, lv_color_hex(CAT_COL[c]), 0); + lv_obj_set_style_bg_opa(d, LV_OPA_COVER, 0); + lv_obj_move_to_index(d, 0); + return v; +} + +// Segment widths: each category's share of the card, the rest of `used` grey. +static void showSegments(uint64_t total, uint64_t used) { + if (!s_sd_bar || !total) return; + lv_obj_update_layout(s_sd_bar); + int w = lv_obj_get_width(s_sd_bar), x = 0; + uint64_t counted = 0; + for (int c = 0; c <= C_COUNT; c++) { + uint64_t b = c < C_COUNT ? s_bytes[c] : (used > counted ? used - counted : 0); + if (c < C_COUNT) counted += b; + int px = (int)((double)b * w / total + 0.5); + if (b && px < 2) px = 2; // a sliver still shows + if (px > w - x) px = w - x; + lv_obj_set_width(s_seg[c], px); + x += px; + } +} + static void showUsage(lv_obj_t* bar, lv_obj_t* lbl, uint64_t total, uint64_t used) { if (!bar || !lbl) return; if (used > total) used = total; - lv_bar_set_value(bar, total ? (int32_t)(used * 1000 / total) : 0, LV_ANIM_OFF); + if (bar == s_sd_bar) showSegments(total, used); + else lv_bar_set_value(bar, total ? (int32_t)(used * 1000 / total) : 0, LV_ANIM_OFF); char u[16], t[16], f[16], line[64]; fmtBytes(u, sizeof(u), used); fmtBytes(t, sizeof(t), total); @@ -201,9 +265,9 @@ void UITask::buildStorage() { if (!lvport::mountStorage()) { infoRow(g, "Card", "None", theme::TEXT_MUTED); } else { - s_sd_bar = usageBar(g, &s_sd_lbl); + s_sd_bar = segmentBar(g, &s_sd_lbl); lv_label_set_text(s_sd_lbl, "Reading the card..."); - for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = infoRow(g, CAT_NAME[c], "...", theme::ACCENT); // what takes the space + for (int c = 0; c < C_COUNT; c++) s_cat_val[c] = catRow(g, c); // what takes the space, keyed to the bar s_status = infoRow(g, "Files", ""); s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment startWalk(); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 00778ff7..5a53a666 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -1491,6 +1491,48 @@ static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text) { return l; } +// Messages section title: a chevron and the title fold the section (tap), +// an "All" / "★ Fav" pill on the right (filter >= 0) flips its +// favourites-only filter. Folded, the title carries the unread count. +enum : uint8_t { FOLD_CHANNELS, FOLD_DIRECT, FOLD_ROOMS }; +static int s_chat_fold = -1; // bit per FOLD_*; -1: not read from NVS yet +static void onChatFold(lv_event_t* e) { s_ui->chatFold((int)(uintptr_t)lv_event_get_user_data(e)); } +static bool chatFolded(int which) { + if (s_chat_fold < 0) s_chat_fold = lvport::loadChatFold(); + return s_chat_fold & (1 << which); +} + +static void chatSection(lv_obj_t* parent, const char* text, int fold, int unread, int filter, bool fav_only) { + bool folded = chatFolded(fold); + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::BG); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), 30); + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_opa(row, LV_OPA_60, LV_STATE_PRESSED); + lv_obj_add_event_cb(row, onChatFold, LV_EVENT_CLICKED, (void*)(uintptr_t)fold); + lv_obj_t* ch = label(row, folded ? LV_SYMBOL_RIGHT : LV_SYMBOL_DOWN, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_align(ch, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_t* t = label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_align(t, LV_ALIGN_LEFT_MID, 22, 0); + if (folded && unread > 0) { // what's waiting inside + lv_obj_t* n = label(row, "", THEME_FONT_SMALL, theme::ACCENT); + lv_label_set_text_fmt(n, "%d new", 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)); +} + // Section title with an "All" / "★ Fav" pill on the right that flips the // section's favourites-only filter. static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, uint8_t which) { @@ -1521,15 +1563,22 @@ static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, void UITask::buildChats() { lv_obj_t* body = newScreen("Messages", true); - if (_header && unreadTotal() > 0) - headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 4, NULL); + if (_header) { // a new message first, whatever the lists below hold + lv_obj_t* nb = headerButton(_header, LV_SYMBOL_EDIT " New", onNewChat, 4, NULL); + stylePrimary(nb); + if (unreadTotal() > 0) { + lv_obj_t* rb = headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 0, NULL); + lv_obj_update_layout(_header); + lv_obj_align_to(rb, nb, LV_ALIGN_OUT_LEFT_MID, -6, 0); + } + } // Channels: favourites first (unless turned off), hold a row for its options bool ch_fav_only = _prefs && _prefs->ch_fav_only; - sectionWithFilter(body, "CHANNELS", ch_fav_only, CF_CHANNELS); + chatSection(body, "CHANNELS", FOLD_CHANNELS, _core->history.getTotalChannelUnread(), CF_CHANNELS, ch_fav_only); bool fav_first = !(_prefs && _prefs->fav_sort_off); int ch_rows = 0; - for (int pass = fav_first ? 0 : 1; pass < 2; pass++) { + for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) { for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { ChannelDetails ch; if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue; @@ -1551,8 +1600,9 @@ void UITask::buildChats() { badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i)); } } - if (ch_rows == 0 && ch_fav_only) label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); + if (ch_rows == 0 && ch_fav_only && !chatFolded(FOLD_CHANNELS)) label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); lv_obj_t* add_ch = lv_button_create(body); + if (chatFolded(FOLD_CHANNELS)) lv_obj_add_flag(add_ch, LV_OBJ_FLAG_HIDDEN); lv_obj_set_size(add_ch, LV_PCT(100), 32); lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0); lv_obj_set_style_border_color(add_ch, lv_color_hex(theme::SURFACE_2), 0); @@ -1563,9 +1613,9 @@ void UITask::buildChats() { lv_obj_center(label(add_ch, LV_SYMBOL_PLUS " Add channel", THEME_FONT_SMALL, theme::TEXT_MUTED)); // Recent direct conversations (DM ring, newest first, one row per contact) - sectionTitle(body, "DIRECT"); + chatSection(body, "DIRECT", FOLD_DIRECT, _core->dmUnreadTotal(), -1, false); int rows = 0; - for (int j = 0; j < _core->history.dmHistCount() && rows < MessageHistory::DM_HIST_MAX; j++) { + for (int j = 0; j < _core->history.dmHistCount() && rows < MessageHistory::DM_HIST_MAX && !chatFolded(FOLD_DIRECT); j++) { const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistPosNewest(j)); bool seen = false; for (int r = 0; r < rows; r++) if (memcmp(s_dm_rows[r], e.prefix, 4) == 0) { seen = true; break; } @@ -1586,18 +1636,14 @@ void UITask::buildChats() { badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix)); rows++; } - if (rows == 0) label(body, "No conversations yet", THEME_FONT_SMALL, theme::TEXT_MUTED); + if (rows == 0 && !chatFolded(FOLD_DIRECT)) label(body, "No conversations yet - New, top right", THEME_FONT_SMALL, theme::TEXT_MUTED); // Room servers: tap logs in (saved password or ask) and opens; hold: options bool room_fav_only = _prefs && _prefs->room_fav_only; - int room_unread = _core->roomUnread(); - char rt[32]; - if (room_unread > 0) snprintf(rt, sizeof(rt), "ROOMS - %d new", room_unread); - else snprintf(rt, sizeof(rt), "ROOMS"); - sectionWithFilter(body, rt, room_fav_only, CF_ROOMS); + chatSection(body, "ROOMS", FOLD_ROOMS, _core->roomUnread(), CF_ROOMS, room_fav_only); int nrooms = 0, total = the_mesh.getNumContacts(); const int MAX_ROOMS = ROOM_ROWS_MAX; - for (int pass = fav_first ? 0 : 1; pass < 2; pass++) { + for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_ROOMS); pass++) { for (int i = 0; i < total && nrooms < MAX_ROOMS; i++) { ContactInfo c; if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue; @@ -1619,16 +1665,19 @@ void UITask::buildChats() { nrooms++; } } - if (nrooms == 0) label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED); + if (nrooms == 0 && !chatFolded(FOLD_ROOMS)) + label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED); +} - lv_obj_t* add = lv_button_create(body); - lv_obj_set_size(add, LV_PCT(100), 36); - lv_obj_set_style_bg_color(add, lv_color_hex(theme::ACCENT_DIM), 0); - lv_obj_set_style_radius(add, theme::RADIUS, 0); - lv_obj_set_style_shadow_width(add, 0, 0); - lv_obj_add_event_cb(add, onNewChat, LV_EVENT_CLICKED, NULL); - lv_obj_center(label(add, LV_SYMBOL_PLUS " New message", THEME_FONT_BODY, theme::TEXT)); - stylePrimary(add); +// A section title tapped: folded / unfolded (kept in NVS), the list redrawn +// where it was. +void UITask::chatFold(int which) { + chatFolded(which); // loaded + s_chat_fold ^= 1 << which; + lvport::saveChatFold(s_chat_fold); + int y = _body ? lv_obj_get_scroll_y(_body) : 0; + buildChats(); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } } // ── Contact picker (start a DM) ─────────────────────────────────────────────── diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 0d5c6ed5..6da57257 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -205,6 +205,7 @@ public: void conversationNotif(int v); void messageMenu(int idx); void messageAction(uint8_t act); + void chatFold(int which); void toggleChatFilter(uint8_t which); // DeviceScreen.h void nodeNamePopup();