mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 18:31:58 +00:00
feat(l2): Gallery app (screenshots as thumbnails, full view with swipe and delete, the saved trails), the clock from NTP while WiFi is up (the router's server from DHCP first), tabs switch on release, Home apps out of Settings
- Gallery: thumbnails 30 to a page, built only while the screen is still (a decode held the loop and a scroll read as a tap) - segmented(): CLICK_TRIG, so a swipe starting on a screen's tabs doesn't switch them - Settings > Device > Home apps removed (hold an app on Home instead) - platformio.ini: a note that the L2 has no microphone on GPIO15 (verified on hardware) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
#pragma once
|
||||
// Home > Gallery. Two tabs:
|
||||
// Screenshots -- the card's screenshots/ (takeScreenshot(): side + top
|
||||
// button), newest first, as a grid of thumbnails PAGE at a
|
||||
// time; built one per loop pass while nothing is touched,
|
||||
// so the grid shows at once and fills in. A tap shows one on the whole screen: swipe
|
||||
// for the next / previous, tap for its name, Delete and close.
|
||||
// Trails -- the saved trails, the list Map tools > Load shows; a tap
|
||||
// opens the map with that trail's Load / GPX / Delete.
|
||||
//
|
||||
// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h.
|
||||
|
||||
namespace gallery {
|
||||
|
||||
static const char* const SHOTS_DIR = "/sdcard/screenshots";
|
||||
static const int SHOTS_MAX = 300; // names kept, newest first
|
||||
static const int PAGE = 30; // thumbnails on the grid at a time
|
||||
static const int TW = 96, TH = 72; // a thumbnail: 3 to a row
|
||||
static const int SW = 320, SH = 240; // the full view (what takeScreenshot() writes)
|
||||
static const int BMP_MAX_W = 640; // wider files aren't ours: not shown
|
||||
|
||||
enum : uint8_t { T_SHOTS, T_TRAILS };
|
||||
enum : uint8_t { V_DELETE, V_CLOSE };
|
||||
|
||||
static PSRAM_BSS char s_names[SHOTS_MAX][16];
|
||||
static int s_n = 0;
|
||||
static int s_page = 0; // the grid's page, from the newest
|
||||
static uint8_t s_tab = T_SHOTS;
|
||||
|
||||
// The grid's thumbnails: one buffer per grid, freed with it (a rebuild makes
|
||||
// the new grid before the old one goes, so each frees only its own).
|
||||
static uint16_t* s_thumbs = nullptr;
|
||||
static lv_image_dsc_t s_tdsc[PAGE];
|
||||
static lv_obj_t* s_cell[PAGE];
|
||||
static int s_cells = 0, s_next = 0; // cells on the grid / the next to build
|
||||
|
||||
// The full view, over the whole screen.
|
||||
static uint16_t* s_full = nullptr;
|
||||
static lv_image_dsc_t s_fdsc;
|
||||
static lv_obj_t* s_view = nullptr;
|
||||
static lv_obj_t* s_view_img = nullptr;
|
||||
static lv_obj_t* s_view_bar = nullptr;
|
||||
static lv_obj_t* s_view_title = nullptr;
|
||||
static lv_obj_t* s_del_lbl = nullptr;
|
||||
static int s_cur = 0;
|
||||
static bool s_changed = false; // a delete from the view: the grid is rebuilt on closing it
|
||||
static lv_point_t s_press;
|
||||
static const int SWIPE_PX = 40;
|
||||
|
||||
static void scan() {
|
||||
s_n = 0;
|
||||
DIR* d = opendir(SHOTS_DIR);
|
||||
if (!d) return;
|
||||
while (struct dirent* de = readdir(d)) {
|
||||
size_t l = strlen(de->d_name);
|
||||
if (de->d_name[0] == '.' || l < 5 || l >= sizeof(s_names[0]) || strcasecmp(de->d_name + l - 4, ".bmp") != 0) continue;
|
||||
if (s_n < SHOTS_MAX) snprintf(s_names[s_n++], sizeof(s_names[0]), "%s", de->d_name);
|
||||
}
|
||||
closedir(d);
|
||||
// Newest first: scr_NNNN numbers count up.
|
||||
qsort(s_names, s_n, sizeof(s_names[0]), [](const void* a, const void* b) {
|
||||
return -strcmp((const char*)a, (const char*)b);
|
||||
});
|
||||
}
|
||||
|
||||
static void path(int i, char* out, size_t n) { snprintf(out, n, "%s/%s", SHOTS_DIR, s_names[i]); }
|
||||
|
||||
// In the display's byte order, as the map tiles are (UI_FLUSH_DMA: big-endian).
|
||||
static inline uint16_t px565(uint32_t r, uint32_t g, uint32_t b) {
|
||||
uint16_t c = (uint16_t)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3));
|
||||
#ifdef UI_FLUSH_DMA
|
||||
return __builtin_bswap16(c);
|
||||
#else
|
||||
return c;
|
||||
#endif
|
||||
}
|
||||
static void dscInit(lv_image_dsc_t& d, uint16_t* px, int w, int h) {
|
||||
memset(&d, 0, sizeof(d));
|
||||
d.header.magic = LV_IMAGE_HEADER_MAGIC;
|
||||
#ifdef UI_FLUSH_DMA
|
||||
d.header.cf = LV_COLOR_FORMAT_RGB565_SWAPPED;
|
||||
#else
|
||||
d.header.cf = LV_COLOR_FORMAT_RGB565;
|
||||
#endif
|
||||
d.header.w = w;
|
||||
d.header.h = h;
|
||||
d.header.stride = w * 2;
|
||||
d.data_size = w * h * 2;
|
||||
d.data = (const uint8_t*)px;
|
||||
}
|
||||
|
||||
// An uncompressed 24-bit BMP, read a row at a time (top row = 0).
|
||||
struct Bmp {
|
||||
FILE* f = nullptr;
|
||||
int32_t w = 0, h = 0;
|
||||
uint32_t off = 0, stride = 0;
|
||||
bool bottom_up = true;
|
||||
bool open(const char* p) {
|
||||
f = fopen(p, "rb");
|
||||
uint8_t hdr[54];
|
||||
if (!f || fread(hdr, 1, 54, f) != 54 || hdr[0] != 'B' || hdr[1] != 'M') return false;
|
||||
auto get32 = [&](int at) { return (uint32_t)hdr[at] | (uint32_t)hdr[at + 1] << 8 | (uint32_t)hdr[at + 2] << 16 | (uint32_t)hdr[at + 3] << 24; };
|
||||
off = get32(10);
|
||||
w = (int32_t)get32(18);
|
||||
h = (int32_t)get32(22);
|
||||
bottom_up = h > 0;
|
||||
if (h < 0) h = -h;
|
||||
const uint16_t bpp = hdr[28] | hdr[29] << 8;
|
||||
if (bpp != 24 || get32(30) != 0 || w <= 0 || w > BMP_MAX_W || h <= 0 || h > BMP_MAX_W) return false;
|
||||
stride = ((uint32_t)w * 3 + 3) & ~3u;
|
||||
return true;
|
||||
}
|
||||
bool row(int y, uint8_t* out) { // BGR
|
||||
if (fseek(f, (long)(off + (uint32_t)(bottom_up ? h - 1 - y : y) * stride), SEEK_SET) != 0) return false;
|
||||
return fread(out, 1, (size_t)w * 3, f) == (size_t)w * 3;
|
||||
}
|
||||
~Bmp() { if (f) fclose(f); }
|
||||
};
|
||||
static PSRAM_BSS uint8_t s_row[2][BMP_MAX_W * 3];
|
||||
|
||||
// File `i` scaled into w x h: each pixel the mean of the 2x2 under its
|
||||
// spot (a thumbnail), or the one pixel there (the full view).
|
||||
static bool decode(int i, uint16_t* out, int w, int h, bool smooth) {
|
||||
char p[48];
|
||||
path(i, p, sizeof(p));
|
||||
Bmp b;
|
||||
if (!b.open(p)) return false;
|
||||
for (int ty = 0; ty < h; ty++) {
|
||||
const int sy = ty * b.h / h, sy2 = smooth && sy + 1 < b.h ? sy + 1 : sy;
|
||||
if (!b.row(sy, s_row[0]) || (sy2 != sy && !b.row(sy2, s_row[1]))) return false;
|
||||
const uint8_t* r1 = s_row[0];
|
||||
const uint8_t* r2 = sy2 != sy ? s_row[1] : s_row[0];
|
||||
for (int tx = 0; tx < w; tx++) {
|
||||
const int sx = tx * b.w / w, sx2 = smooth && sx + 1 < b.w ? sx + 1 : sx;
|
||||
const uint8_t* a = r1 + sx * 3; const uint8_t* c = r1 + sx2 * 3;
|
||||
const uint8_t* d = r2 + sx * 3; const uint8_t* e = r2 + sx2 * 3;
|
||||
out[ty * w + tx] = px565((a[2] + c[2] + d[2] + e[2]) / 4, (a[1] + c[1] + d[1] + e[1]) / 4,
|
||||
(a[0] + c[0] + d[0] + e[0]) / 4);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void onGridDeleted(lv_event_t* e) {
|
||||
uint16_t* px = (uint16_t*)lv_event_get_user_data(e);
|
||||
if (px != s_thumbs) { free(px); return; } // an older grid's
|
||||
for (int i = 0; i < s_cells; i++) lv_image_cache_drop(&s_tdsc[i]);
|
||||
free(s_thumbs);
|
||||
s_thumbs = nullptr;
|
||||
s_cells = s_next = 0;
|
||||
}
|
||||
static void onViewDeleted(lv_event_t* e) {
|
||||
(void)e;
|
||||
s_view = s_view_img = s_view_bar = s_view_title = s_del_lbl = nullptr;
|
||||
lv_image_cache_drop(&s_fdsc);
|
||||
free(s_full);
|
||||
s_full = nullptr;
|
||||
}
|
||||
|
||||
} // namespace gallery
|
||||
|
||||
static void onOpenGallery(lv_event_t* e) { (void)e; s_ui->showGallery(); }
|
||||
static void onGalleryTab(lv_event_t* e) {
|
||||
s_ui->galleryTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
|
||||
}
|
||||
static void onGalleryShot(lv_event_t* e) { s_ui->galleryOpen((int)(intptr_t)lv_event_get_user_data(e)); }
|
||||
static void onGalleryPage(lv_event_t* e) { s_ui->galleryPage((int)(intptr_t)lv_event_get_user_data(e)); }
|
||||
static void onGalleryTrail(lv_event_t* e) { s_ui->galleryTrail((int)(intptr_t)lv_event_get_user_data(e)); }
|
||||
static void onGalleryViewBtn(lv_event_t* e) { s_ui->galleryViewAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
// The full view: a swipe steps, a tap shows / hides the bar.
|
||||
static void onGalleryViewTouch(lv_event_t* e) {
|
||||
using namespace gallery;
|
||||
lv_point_t p;
|
||||
lv_indev_get_point(lv_indev_active(), &p);
|
||||
if (lv_event_get_code(e) == LV_EVENT_PRESSED) { s_press = p; return; }
|
||||
const int dx = p.x - s_press.x, dy = p.y - s_press.y;
|
||||
if (abs(dx) > SWIPE_PX && abs(dx) > abs(dy)) s_ui->galleryStep(dx < 0 ? 1 : -1);
|
||||
else if (abs(dx) < 10 && abs(dy) < 10 && s_view_bar) {
|
||||
if (lv_obj_has_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN)) lv_obj_remove_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
else lv_obj_add_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::showGallery() {
|
||||
_screen = SCR_GALLERY;
|
||||
gallery::s_page = 0;
|
||||
buildGallery();
|
||||
}
|
||||
|
||||
void UITask::galleryTab(int tab) {
|
||||
if (tab != gallery::T_SHOTS && tab != gallery::T_TRAILS) return;
|
||||
gallery::s_tab = (uint8_t)tab;
|
||||
buildGallery();
|
||||
}
|
||||
|
||||
void UITask::galleryPage(int page) {
|
||||
gallery::s_page = page;
|
||||
buildGallery();
|
||||
}
|
||||
|
||||
void UITask::buildGallery() {
|
||||
using namespace gallery;
|
||||
lv_obj_t* body = newScreen("Gallery", true);
|
||||
lv_obj_set_style_pad_row(body, 4, 0);
|
||||
static const char* TABS[] = { "Screenshots", "Trails", "" };
|
||||
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), theme::TAB_H);
|
||||
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
|
||||
lv_obj_add_event_cb(tabs, onGalleryTab, LV_EVENT_VALUE_CHANGED, NULL);
|
||||
lv_obj_t* list = scrollList(body);
|
||||
const bool card = lvport::mountStorage();
|
||||
|
||||
if (s_tab == T_TRAILS) {
|
||||
if (card) {
|
||||
navmap::scanTrails();
|
||||
for (int i = 0; i < navmap::s_st_n; i++) {
|
||||
char title[32], sub[48], p[64];
|
||||
navmap::trailTitle(navmap::s_st_names[i], title, sizeof(title));
|
||||
snprintf(p, sizeof(p), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[i]);
|
||||
struct stat st;
|
||||
long pts = stat(p, &st) == 0 ? ((long)st.st_size - navmap::TRAIL_HDR) / (long)sizeof(TrailPoint) : 0;
|
||||
snprintf(sub, sizeof(sub), "%ld points", pts > 0 ? pts : 0);
|
||||
listRow(list, title, sub, onGalleryTrail, (void*)(intptr_t)i);
|
||||
}
|
||||
}
|
||||
if (TrailEngine::savedExists())
|
||||
listRow(list, "Saved on the device", "Without a card, or on low battery", onGalleryTrail, (void*)(intptr_t)-1);
|
||||
if (lv_obj_get_child_count(list) == 0)
|
||||
noteLabel(list, "No saved trails yet - Map tools > Save keeps the one on the map.", THEME_FONT_BODY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!card) { noteLabel(list, "No SD card - screenshots are saved on it.", THEME_FONT_BODY); return; }
|
||||
scan();
|
||||
if (s_n == 0) {
|
||||
noteLabel(list, "No screenshots yet. Hold the side button and press the top one to take one.", THEME_FONT_BODY);
|
||||
return;
|
||||
}
|
||||
if (_header) {
|
||||
lv_obj_t* c = label(_header, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||
lv_label_set_text_fmt(c, "%d", s_n);
|
||||
lv_obj_align(c, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
|
||||
}
|
||||
const int pages = (s_n + PAGE - 1) / PAGE;
|
||||
if (s_page >= pages) s_page = pages - 1;
|
||||
if (s_page < 0) s_page = 0;
|
||||
const int first = s_page * PAGE;
|
||||
const int cells = s_n - first < PAGE ? s_n - first : PAGE;
|
||||
|
||||
uint16_t* px = (uint16_t*)psramBuf<uint16_t>((size_t)PAGE * TW * TH);
|
||||
if (!px) { noteLabel(list, "Out of memory", THEME_FONT_BODY); return; }
|
||||
s_thumbs = px;
|
||||
s_cells = cells;
|
||||
s_next = 0;
|
||||
lv_obj_t* grid = lv_obj_create(list);
|
||||
lv_obj_remove_style_all(grid);
|
||||
fillWidth(grid);
|
||||
lv_obj_set_height(grid, LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
||||
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_set_style_pad_row(grid, 6, 0);
|
||||
lv_obj_add_event_cb(grid, gallery::onGridDeleted, LV_EVENT_DELETE, px);
|
||||
for (int i = 0; i < cells; i++) {
|
||||
lv_obj_t* c = lv_obj_create(grid);
|
||||
styleSurface(c, theme::SURFACE);
|
||||
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_set_size(c, TW, TH);
|
||||
lv_obj_set_style_pad_all(c, 0, 0);
|
||||
lv_obj_add_event_cb(c, onGalleryShot, LV_EVENT_CLICKED, (void*)(intptr_t)(first + i));
|
||||
s_cell[i] = c;
|
||||
}
|
||||
if (pages > 1) {
|
||||
lv_obj_t* bar = buttonBar(list);
|
||||
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
if (s_page > 0) barButton(bar, LV_SYMBOL_LEFT " Newer", onGalleryPage, s_page - 1);
|
||||
lv_obj_t* n = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||
lv_label_set_text_fmt(n, "%d-%d of %d", first + 1, first + cells, s_n);
|
||||
if (s_page + 1 < pages) barButton(bar, "Older " LV_SYMBOL_RIGHT, onGalleryPage, s_page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// From loop() on the gallery: the next thumbnail, only with the screen let
|
||||
// go and still. A decode holds the loop up, and LVGL, missing the finger's
|
||||
// moves meanwhile, took a scroll for a tap on the picture under it.
|
||||
void UITask::galleryTick() {
|
||||
using namespace gallery;
|
||||
if (!s_thumbs || s_next >= s_cells || s_view) return;
|
||||
lv_indev_t* in = lv_indev_get_next(NULL);
|
||||
if (in && lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED) return;
|
||||
if (lv_display_get_inactive_time(NULL) < 300 || lv_anim_count_running() > 0) return; // a scroll coasting
|
||||
const int i = s_next++;
|
||||
uint16_t* px = s_thumbs + (size_t)i * TW * TH;
|
||||
if (!decode(s_page * PAGE + i, px, TW, TH, true)) {
|
||||
lv_obj_center(label(s_cell[i], "?", THEME_FONT_LARGE, theme::TEXT_MUTED));
|
||||
return;
|
||||
}
|
||||
dscInit(s_tdsc[i], px, TW, TH);
|
||||
lv_obj_t* img = lv_image_create(s_cell[i]);
|
||||
lv_obj_remove_flag(img, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_image_set_src(img, &s_tdsc[i]);
|
||||
lv_obj_center(img);
|
||||
}
|
||||
|
||||
void UITask::galleryTrail(int idx) {
|
||||
openMap(true);
|
||||
savedTrailPopup(idx);
|
||||
}
|
||||
|
||||
// Screenshot `i` on the whole screen.
|
||||
void UITask::galleryOpen(int i) {
|
||||
using namespace gallery;
|
||||
if (i < 0 || i >= s_n) return;
|
||||
if (!s_full) s_full = (uint16_t*)psramBuf<uint16_t>((size_t)SW * SH);
|
||||
if (!s_full) { showToast("Out of memory"); return; }
|
||||
if (s_view_img) lv_image_cache_drop(&s_fdsc);
|
||||
if (!decode(i, s_full, SW, SH, false)) { showToast("Can't read this file"); return; }
|
||||
s_cur = i;
|
||||
dscInit(s_fdsc, s_full, SW, SH);
|
||||
if (!s_view) {
|
||||
s_changed = false;
|
||||
s_view = lv_obj_create(_scr);
|
||||
lv_obj_remove_style_all(s_view);
|
||||
lv_obj_add_flag(s_view, LV_OBJ_FLAG_IGNORE_LAYOUT);
|
||||
lv_obj_set_pos(s_view, 0, 0);
|
||||
lv_obj_set_size(s_view, SW, SH);
|
||||
lv_obj_set_style_bg_color(s_view, lv_color_black(), 0);
|
||||
lv_obj_set_style_bg_opa(s_view, LV_OPA_COVER, 0);
|
||||
lv_obj_remove_flag(s_view, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(s_view, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(s_view, onGalleryViewTouch, LV_EVENT_PRESSED, NULL);
|
||||
lv_obj_add_event_cb(s_view, onGalleryViewTouch, LV_EVENT_RELEASED, NULL);
|
||||
lv_obj_add_event_cb(s_view, gallery::onViewDeleted, LV_EVENT_DELETE, NULL);
|
||||
s_view_img = lv_image_create(s_view);
|
||||
lv_obj_remove_flag(s_view_img, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_set_pos(s_view_img, 0, 0);
|
||||
|
||||
s_view_bar = lv_obj_create(s_view);
|
||||
styleSurface(s_view_bar, theme::SURFACE);
|
||||
lv_obj_set_style_bg_opa(s_view_bar, LV_OPA_90, 0);
|
||||
lv_obj_remove_flag(s_view_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_size(s_view_bar, SW, 40);
|
||||
lv_obj_set_pos(s_view_bar, 0, 0);
|
||||
lv_obj_set_style_pad_hor(s_view_bar, theme::PAD, 0);
|
||||
lv_obj_set_flex_flow(s_view_bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(s_view_bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_column(s_view_bar, 6, 0);
|
||||
s_view_title = label(s_view_bar, "", THEME_FONT_BODY, theme::TEXT);
|
||||
lv_obj_set_flex_grow(s_view_title, 1);
|
||||
lv_obj_t* del = pillButton(s_view_bar, LV_SYMBOL_TRASH " Delete", theme::SURFACE_2, onGalleryViewBtn, V_DELETE);
|
||||
lv_obj_set_height(del, 32);
|
||||
s_del_lbl = lv_obj_get_child(del, 0);
|
||||
lv_obj_set_height(pillButton(s_view_bar, LV_SYMBOL_CLOSE, theme::SURFACE_2, onGalleryViewBtn, V_CLOSE), 32);
|
||||
lv_obj_add_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN); // a tap brings it
|
||||
}
|
||||
lv_image_set_src(s_view_img, &s_fdsc);
|
||||
lv_obj_invalidate(s_view_img);
|
||||
lv_label_set_text_fmt(s_view_title, "%.*s %d / %d", (int)(strlen(s_names[i]) - 4), s_names[i], i + 1, s_n);
|
||||
}
|
||||
|
||||
void UITask::galleryStep(int d) {
|
||||
const int i = gallery::s_cur + d;
|
||||
if (i >= 0 && i < gallery::s_n) galleryOpen(i);
|
||||
}
|
||||
|
||||
void UITask::galleryCloseView() {
|
||||
using namespace gallery;
|
||||
if (!s_view) return;
|
||||
lv_obj_delete(s_view); // onViewDeleted frees the picture
|
||||
// Back on the page the one looked at last is on.
|
||||
if (s_changed || s_cur / PAGE != s_page) { s_page = s_cur / PAGE; buildGallery(); }
|
||||
}
|
||||
|
||||
void UITask::galleryBack() {
|
||||
if (gallery::s_view) galleryCloseView();
|
||||
else showHome();
|
||||
}
|
||||
|
||||
void UITask::galleryViewAction(uint8_t act) {
|
||||
using namespace gallery;
|
||||
if (act == V_CLOSE) { galleryCloseView(); return; }
|
||||
if (!tapConfirmed(s_del_lbl, "Delete?")) return;
|
||||
char p[48];
|
||||
path(s_cur, p, sizeof(p));
|
||||
if (remove(p) != 0) { showToast("Delete failed"); return; }
|
||||
s_changed = true;
|
||||
scan();
|
||||
if (s_n == 0) { s_cur = 0; galleryCloseView(); return; }
|
||||
galleryOpen(s_cur < s_n ? s_cur : s_n - 1);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
// map -- a minimap round your position, framing anyone sharing
|
||||
// theirs and the target; tap for the Navigation map
|
||||
// apps -- the tools, 3x2 to a page, in the user's order with some
|
||||
// maybe hidden (Settings > Home apps, or hold an app)
|
||||
// maybe hidden (hold an app)
|
||||
//
|
||||
// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h (the
|
||||
// minimap draws with the map's tile cache).
|
||||
@@ -34,6 +34,7 @@ static const App APP_LIST[] = {
|
||||
{ 'A', UI_SYMBOL_KEY, "Admin", onOpenAdminPick, false },
|
||||
{ 'D', UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false },
|
||||
{ 'L', UI_SYMBOL_BULB, "Flashlight", onOpenFlashlight, false },
|
||||
{ 'P', LV_SYMBOL_IMAGE, "Gallery", onOpenGallery, false },
|
||||
};
|
||||
static const int APP_COUNT = sizeof(APP_LIST) / sizeof(APP_LIST[0]);
|
||||
static const int PER_PAGE = 6;
|
||||
@@ -41,7 +42,7 @@ static const char ALWAYS = 'S'; // Settings can't be hidden: it's the way back
|
||||
|
||||
// ── The user's order ──
|
||||
// The letters in order, a hidden app's in lower case. Arranged on Home itself
|
||||
// (hold an app, or Settings > Home apps); saved on Done or on leaving Home,
|
||||
// (hold an app); saved on Done or on leaving Home,
|
||||
// not per change.
|
||||
static char s_order[APP_COUNT + 1];
|
||||
static bool s_order_loaded = false, s_order_dirty = false;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <USBMSC.h>
|
||||
#endif
|
||||
#include <mbedtls/platform.h>
|
||||
#include <esp_sntp.h>
|
||||
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
||||
#include <emscripten/fetch.h>
|
||||
#include <dirent.h>
|
||||
@@ -573,8 +574,10 @@ static bool flashInfo(uint64_t& total, uint64_t& used) {
|
||||
// straight away when it's the only one.
|
||||
static bool s_net_scanning = false;
|
||||
static char s_fb_ssid[33], s_fb_pass[65];
|
||||
static void ntpAskDhcp();
|
||||
static void netBegin(const char* ssid, const char* pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
ntpAskDhcp();
|
||||
snprintf(s_fb_ssid, sizeof(s_fb_ssid), "%s", ssid);
|
||||
snprintf(s_fb_pass, sizeof(s_fb_pass), "%s", pass);
|
||||
if (wifiSavedCount() <= 1) { WiFi.begin(ssid, pass); return; }
|
||||
@@ -585,6 +588,7 @@ static void netBegin(const char* ssid, const char* pass) {
|
||||
static void netJoin(const char* ssid, const char* pass) {
|
||||
s_net_scanning = false;
|
||||
WiFi.mode(WIFI_STA);
|
||||
ntpAskDhcp();
|
||||
WiFi.begin(ssid, pass);
|
||||
}
|
||||
// After NET_FAILED / a timeout: true when the network wasn't there at all
|
||||
@@ -615,6 +619,29 @@ static int netState() {
|
||||
default: return NET_CONNECTING;
|
||||
}
|
||||
}
|
||||
// The clock over WiFi (SNTP), while connected: the router's own NTP server
|
||||
// when its DHCP lease names one (option 42 -- asked for before joining, the
|
||||
// lease is where it comes from), then public pools. SNTP sets the system time
|
||||
// itself; ntpTake() hands the time on to rtc_clock (a hardware RTC, if any).
|
||||
static volatile uint32_t s_ntp_secs = 0;
|
||||
static char s_ntp_dhcp[16];
|
||||
static void ntpAskDhcp() { esp_sntp_servermode_dhcp(true); }
|
||||
static void ntpSynced(struct timeval* tv) { s_ntp_secs = (uint32_t)tv->tv_sec; } // the lwIP task
|
||||
static void ntpStart() {
|
||||
if (esp_sntp_enabled()) esp_sntp_stop();
|
||||
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||
const ip_addr_t* a = esp_sntp_getserver(0);
|
||||
const bool router = a && !ip_addr_isany(a);
|
||||
if (router) snprintf(s_ntp_dhcp, sizeof(s_ntp_dhcp), "%s", ipaddr_ntoa(a));
|
||||
esp_sntp_setservername(0, router ? s_ntp_dhcp : "pool.ntp.org");
|
||||
esp_sntp_setservername(1, router ? "pool.ntp.org" : "time.google.com");
|
||||
esp_sntp_setservername(2, "time.cloudflare.com");
|
||||
sntp_set_time_sync_notification_cb(ntpSynced);
|
||||
esp_sntp_init();
|
||||
}
|
||||
static void ntpStop() { if (esp_sntp_enabled()) esp_sntp_stop(); }
|
||||
static uint32_t ntpTake() { uint32_t t = s_ntp_secs; s_ntp_secs = 0; return t; }
|
||||
|
||||
static void fetchFreeBuffer();
|
||||
static void netEnd() {
|
||||
if (s_net_scanning) { s_net_scanning = false; WiFi.scanDelete(); }
|
||||
@@ -912,6 +939,9 @@ static bool netNotFound() { return false; }
|
||||
static int netState() { return NET_UP; }
|
||||
static void netEnd() { s_net_on = false; }
|
||||
static int netRadio() { return s_net_on ? NET_UP : NET_OFF; }
|
||||
static void ntpStart() {} // the browser's clock is the computer's already
|
||||
static void ntpStop() {}
|
||||
static uint32_t ntpTake() { return 0; }
|
||||
static void netSsid(char* out, size_t n) { const WifiNet* w = wifiSaved(0); snprintf(out, n, "%s", s_net_on && w ? w->ssid : ""); }
|
||||
static uint32_t s_scan_at = 0;
|
||||
static void scanStart() { s_scan_at = millis(); }
|
||||
|
||||
@@ -66,7 +66,6 @@ static void onOpenSchemaPage(lv_event_t* e) {
|
||||
s_nav_section = -1; // from Settings: all of a page
|
||||
s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e));
|
||||
}
|
||||
static void onHomeApps(lv_event_t* e) { (void)e; s_ui->homeEdit(true); }
|
||||
static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); }
|
||||
static void onOpenQuickMsgs(lv_event_t* e);
|
||||
static void onPinSetup(lv_event_t* e); // DeviceScreen.h
|
||||
@@ -574,8 +573,6 @@ void UITask::settingsGroup(int i) {
|
||||
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), "Order, hide - %d of %d shown", home::shownCount(), home::APP_COUNT);
|
||||
listRow(g, LV_SYMBOL_HOME " Home apps", sub, onHomeApps, NULL);
|
||||
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);
|
||||
|
||||
@@ -324,6 +324,7 @@ void UITask::loop() {
|
||||
#endif
|
||||
checkLowBattery();
|
||||
mapDownloadTick(); // a map download keeps running on any screen, and asleep
|
||||
wifiClockTick();
|
||||
otaTick(); // so does a firmware update
|
||||
trailJournalTick(); // the live trail's copy on the card, once a minute
|
||||
if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen
|
||||
@@ -347,6 +348,7 @@ void UITask::loop() {
|
||||
}
|
||||
if (locked()) lockPoll();
|
||||
else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); }
|
||||
else if (_screen == SCR_GALLERY) galleryTick();
|
||||
if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) {
|
||||
// Scan results trickle in over a few seconds: poll fast while scanning.
|
||||
_next_nearby_ms = millis() + (_scanning ? 250 : 2000);
|
||||
@@ -1229,7 +1231,7 @@ int UITask::screenDepth(Screen s) {
|
||||
switch (s) {
|
||||
case SCR_HOME: return 0;
|
||||
case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS:
|
||||
case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
|
||||
case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
|
||||
case SCR_DIAG: case SCR_GPS: return 1;
|
||||
case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE:
|
||||
case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2;
|
||||
@@ -1255,6 +1257,7 @@ void UITask::back() {
|
||||
case SCR_ROUTE_PICK: _screen = SCR_ROUTE; buildRoute(); break;
|
||||
case SCR_OTA: otaLeave(); break;
|
||||
case SCR_COMPASS: showHome(); break;
|
||||
case SCR_GALLERY: galleryBack(); break;
|
||||
case SCR_BATT: showSchemaSettings(PG_POWER); break;
|
||||
case SCR_GPS: if (_gps_from_settings) showSettings(); else showHome(); break;
|
||||
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
|
||||
@@ -1338,6 +1341,7 @@ static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); }
|
||||
static void onOpenClock(lv_event_t* e); // ClockScreen.h
|
||||
static void onOpenBot(lv_event_t* e); // BotScreen.h
|
||||
static void onOpenCompass(lv_event_t* e); // CompassScreen.h
|
||||
static void onOpenGallery(lv_event_t* e); // GalleryScreen.h
|
||||
static void onOpenFlashlight(lv_event_t* e); // Flashlight.h
|
||||
static void onOpenDiag(lv_event_t* e); // DiagScreen.h
|
||||
static void onOpenGps(lv_event_t* e); // GpsScreen.h
|
||||
@@ -1360,6 +1364,7 @@ static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr
|
||||
#include "Chart.h"
|
||||
#include "ProfileView.h"
|
||||
#include "NavMap.h"
|
||||
#include "GalleryScreen.h"
|
||||
#include "MapAreas.h"
|
||||
#include "MapRegions.h"
|
||||
#include "HomeScreen.h"
|
||||
@@ -1442,7 +1447,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
|
||||
{ "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi },
|
||||
{ "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes },
|
||||
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage },
|
||||
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick },
|
||||
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "gallery", &UITask::showGallery }, { "admin", &UITask::showAdminPick },
|
||||
{ "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts },
|
||||
};
|
||||
for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; }
|
||||
|
||||
@@ -77,7 +77,7 @@ public:
|
||||
// ── Navigation (called from LVGL event callbacks) ────────────────────────
|
||||
void showHome();
|
||||
void setHomePage(int page);
|
||||
void homeEdit(bool on); // Home's apps: drag to move, tap to hide (hold an app, or Settings > Home apps)
|
||||
void homeEdit(bool on); // Home's apps: drag to move, tap to hide (hold an app)
|
||||
void homeAppDrop(int from, int to);
|
||||
void homeAppToggle(int pos);
|
||||
void goHome(); // the side button: Home's clock page
|
||||
@@ -369,6 +369,13 @@ public:
|
||||
void diagReset();
|
||||
void diagNoiseRun();
|
||||
void showCompass();
|
||||
void showGallery();
|
||||
void galleryTab(int tab);
|
||||
void galleryPage(int page);
|
||||
void galleryOpen(int i); // a screenshot on the whole screen
|
||||
void galleryStep(int d);
|
||||
void galleryViewAction(uint8_t act);
|
||||
void galleryTrail(int idx); // -1 = the device's own slot
|
||||
void showFlashlight(); // Flashlight.h
|
||||
void flashlightClose();
|
||||
bool flashlightOn() const;
|
||||
@@ -394,7 +401,7 @@ public:
|
||||
void statusBarSync(); // up or hidden, by the screen's title bar (UITask.cpp)
|
||||
|
||||
private:
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK };
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY };
|
||||
|
||||
void buildStatusBar();
|
||||
void refreshStatusBar();
|
||||
@@ -487,6 +494,8 @@ private:
|
||||
bool navCurrentTarget(int32_t& lat, int32_t& lon, char* name, int n, bool& person, bool& set);
|
||||
bool exportTrailGpx(char* name_out, size_t n);
|
||||
void mapDownloadTick();
|
||||
void wifiClockTick(); // the clock from NTP while WiFi is up (LvglPort.h ntpStart())
|
||||
bool _ntp_on = false;
|
||||
void mapLiveBegin();
|
||||
void refreshDownloadPopup();
|
||||
void buildWifi();
|
||||
@@ -538,6 +547,10 @@ private:
|
||||
void sampleHistory(); // DiagScreen.h: History's readings, from loop()
|
||||
void fillHistory();
|
||||
void buildCompass();
|
||||
void buildGallery();
|
||||
void galleryTick();
|
||||
void galleryCloseView();
|
||||
void galleryBack(); // the full view closes, else Home
|
||||
void refreshCompass();
|
||||
void favPickPopup(int slot);
|
||||
void adminValuePopup();
|
||||
|
||||
@@ -957,11 +957,14 @@ static int32_t s_settings_y = 0; // Settings' scroll, kept wh
|
||||
// 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.
|
||||
// Picked on release over the same button (CLICK_TRIG), not on press as
|
||||
// lv_buttonmatrix does by default: a swipe starting on a screen's tabs (to
|
||||
// scroll the list under them back up) switched the tab.
|
||||
static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h,
|
||||
uint32_t item = theme::SURFACE_2) {
|
||||
lv_obj_t* m = lv_buttonmatrix_create(parent);
|
||||
lv_buttonmatrix_set_map(m, map);
|
||||
lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE);
|
||||
lv_buttonmatrix_set_button_ctrl_all(m, (lv_buttonmatrix_ctrl_t)(LV_BUTTONMATRIX_CTRL_CHECKABLE | LV_BUTTONMATRIX_CTRL_CLICK_TRIG));
|
||||
lv_buttonmatrix_set_one_checked(m, true);
|
||||
if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
|
||||
lv_obj_set_size(m, w, h);
|
||||
|
||||
@@ -319,3 +319,28 @@ void UITask::wifiSave() {
|
||||
snprintf(msg, sizeof(msg), LV_SYMBOL_REFRESH " Connecting to %s...", s_wifi_test_ssid);
|
||||
wifiTestShow(msg, nullptr, nullptr);
|
||||
}
|
||||
|
||||
// From loop(), any screen, once a second: SNTP runs while WiFi is connected
|
||||
// (Settings > WiFi, a map download, an update, the app over WiFi), and each
|
||||
// time it answers the clock takes the time -- ahead or behind, as from GPS.
|
||||
// The first answer of a connection says so when it moved the clock.
|
||||
void UITask::wifiClockTick() {
|
||||
static uint32_t next_ms = 0;
|
||||
static bool first = false;
|
||||
if ((int32_t)(millis() - next_ms) < 0) return;
|
||||
next_ms = millis() + 1000;
|
||||
const bool up = lvport::netRadio() == lvport::NET_UP;
|
||||
if (up != _ntp_on) {
|
||||
_ntp_on = up;
|
||||
if (up) lvport::ntpStart(); else lvport::ntpStop();
|
||||
first = up;
|
||||
}
|
||||
const uint32_t t = lvport::ntpTake();
|
||||
if (t > 1700000000UL) {
|
||||
const uint32_t now = rtc_clock.getCurrentTime();
|
||||
const bool moved = t > now + 1 || now > t + 1;
|
||||
if (moved) rtc_clock.setCurrentTime(t);
|
||||
if (first && moved && !_asleep) showToast("Clock set from the network");
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user