mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
The panel froze and dimmed the screen as the finger started: a snapshot of the whole screen (slow on the map) and a full-screen flush, so it stalled, jumped and tore top to bottom. Now it slides opaque over the live screen (a clear layer under it still takes the tap that closes it), built at start. Its slide is timed by its own frames, at most 30 ms a frame: a slow frame ended the 160 ms lv_anim and the panel just appeared. The sky plot on GPS > Sky was a fixed 168 px, taller than what is left under the title bar and tabs: now it takes that height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1471 lines
66 KiB
C++
1471 lines
66 KiB
C++
#include "UITask.h"
|
|
#include <helpers/TxtDataHelpers.h>
|
|
#include "../MyMesh.h"
|
|
#include "target.h"
|
|
#if defined(ESP32)
|
|
#include <esp_heap_caps.h>
|
|
// The UI runs on Arduino's loop task: LVGL's renderer, FAT on the card and
|
|
// the map's decoders all nest on its stack, and the stock 8 KB left a few
|
|
// hundred bytes at the deepest point (Diagnostics > Stack free, the lowest
|
|
// ever) -- a screenshot's file write went past it and reset the device.
|
|
SET_LOOP_TASK_STACK_SIZE(16 * 1024);
|
|
#endif
|
|
#include <new>
|
|
#include <src/core/lv_obj_event_private.h> // lv_cover_check_info_t (coversOwnArea)
|
|
#include <src/widgets/label/lv_label_private.h> // lv_label_t (labelShows)
|
|
#include <src/misc/lv_area_private.h> // lv_area_intersect (clockview::wheelDraw)
|
|
#include <stdarg.h>
|
|
#include <sys/stat.h>
|
|
|
|
// Flags: LVGL draws text one codepoint at a time, so a flag (a pair of regional
|
|
// indicator letters) would show as two letter tiles. Every label and span of
|
|
// this UI (all of it is this one file and the headers it includes) goes through
|
|
// these, which swap each pair for the codepoint fonts/ui_emoji.c keeps the
|
|
// flag's image under. Display only: what's stored and sent stays as it was.
|
|
// A label given the text it already shows is left alone: the status bar, the
|
|
// clocks and the live values are set every refresh, and each set is a redraw.
|
|
extern "C" char* ui_emoji_flags(const char* s);
|
|
static void ui_label_set_text(lv_obj_t* o, const char* t) {
|
|
char* f = t ? ui_emoji_flags(t) : nullptr; // t == NULL: LVGL's "redraw the current text"
|
|
const char* s = f ? f : t;
|
|
const char* cur = lv_label_get_text(o);
|
|
if (!s || !cur || strcmp(cur, s) != 0) lv_label_set_text(o, s);
|
|
lv_free(f);
|
|
}
|
|
static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
|
|
static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) {
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
char buf[160];
|
|
int n = vsnprintf(buf, sizeof(buf), fmt, ap);
|
|
va_end(ap);
|
|
if (n < (int)sizeof(buf)) { ui_label_set_text(o, buf); return; }
|
|
char* big = (char*)lv_malloc(n + 1);
|
|
if (!big) return;
|
|
va_start(ap, fmt);
|
|
vsnprintf(big, n + 1, fmt, ap);
|
|
va_end(ap);
|
|
ui_label_set_text(o, big);
|
|
lv_free(big);
|
|
}
|
|
static void ui_span_set_text(lv_span_t* s, const char* t) {
|
|
char* f = ui_emoji_flags(t);
|
|
lv_span_set_text(s, f ? f : t);
|
|
lv_free(f);
|
|
}
|
|
#define lv_label_set_text ui_label_set_text
|
|
#define lv_label_set_text_fmt ui_label_set_text_fmt
|
|
#define lv_span_set_text ui_span_set_text
|
|
|
|
// A zeroed buffer of `n` T in PSRAM when the board has it: internal RAM is what
|
|
// BLE, WiFi and TLS need. Allocated once (at startup or first use), never freed.
|
|
template <class T> static T* psramBuf(size_t n) {
|
|
#if defined(ESP32)
|
|
if (void* p = heap_caps_calloc(n, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) return (T*)p;
|
|
#endif
|
|
return (T*)calloc(n, sizeof(T));
|
|
}
|
|
|
|
static bool s_chats_stale = false; // Messages: a refresh waits for a held row to be let go
|
|
|
|
#include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU)
|
|
#include "../ui-core/NearbyModel.h"
|
|
#include "../ui-core/EtaTracker.h"
|
|
#include "../ui-core/SettingsSchema.h"
|
|
#include "../ui-core/SettingsCli.h" // the companion CLI's settings keys
|
|
#include "../ui-core/ContactQr.h"
|
|
#include "../ui-core/GpsAverager.h"
|
|
#include "../ui-core/TrackBack.h"
|
|
#include "../ui-core/TrailProfile.h"
|
|
#include "../ui-core/StatusHistory.h"
|
|
#include "../ui-core/CalendarMath.h"
|
|
#include "../ui-core/RadioControl.h"
|
|
#include "../ui-core/RepeaterControl.h"
|
|
#include "../ui-core/Diagnostics.h"
|
|
#include "../ui-core/Battery.h"
|
|
#include "../ui-core/Telemetry.h"
|
|
#include "../ui-core/ChannelControl.h"
|
|
#include "../ui-core/BotConfig.h"
|
|
#include "../ui-core/SoundControl.h"
|
|
#include "../ui-core/SoundNotifier.h"
|
|
#include "../ui-core/MessageText.h"
|
|
#include "../ui-core/SiteQr.h"
|
|
#include "Theme.h"
|
|
#include "Anim.h"
|
|
#include "LvglPort.h"
|
|
#include "lv_mem_psram.h"
|
|
#include "HistoryStore.h"
|
|
#include "map/MapLayers.h"
|
|
#include "map/LiveCache.h"
|
|
static histstore::SdArchive s_archive; // message history on the SD card
|
|
namespace storeview { static void stopWalk(); } // StorageScreen.h
|
|
#include "../ui-core/KeyboardData.h"
|
|
#include "Keyboard.h"
|
|
|
|
static const char* waypointsFull() {
|
|
static char t[24];
|
|
snprintf(t, sizeof(t), "Waypoints full (%d)", WaypointStore::CAPACITY);
|
|
return t;
|
|
}
|
|
|
|
// ui-lvgl skeleton (docs/solo/developer/ui-core.md, step 5): status bar, home,
|
|
// conversation list, contact picker, conversation view with compose. Every
|
|
// piece of state it shows comes from the UI Core; this file only draws it.
|
|
|
|
static UITask* s_ui = nullptr; // for LVGL's C callbacks
|
|
|
|
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
#include <emscripten.h>
|
|
// The board's one button, pressed from the simulator page (web/lvgl.html).
|
|
static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false;
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; }
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; }
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; }
|
|
static bool s_sim_shot = false; // the side + top button combo
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_screenshot() { s_sim_shot = true; }
|
|
// The speaker, polled every frame by the page's Web Audio oscillator.
|
|
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { return s_ui && s_ui->isBuzzerPlaying() ? 1 : 0; }
|
|
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ui->buzzerFreqHz() : 0; }
|
|
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_get_volume() { return s_ui ? (int)s_ui->buzzerVolume() : 0; }
|
|
#endif
|
|
|
|
#include "Widgets.h"
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
|
|
void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs* node_prefs) {
|
|
s_ui = this;
|
|
_ui_started_ms = millis();
|
|
_display = display_drv;
|
|
_sensors = sensors;
|
|
_prefs = node_prefs;
|
|
s_prefs = node_prefs;
|
|
if (node_prefs) battery::useCurve(node_prefs->batt_curve_mv); // Settings > Battery curve
|
|
|
|
_core = new UiCore();
|
|
_core->begin(node_prefs, sensors, this);
|
|
{ // history kept on the SD card: back into the ring, then every new entry to it
|
|
int hk = lvport::loadHistKeep();
|
|
s_archive.setKeep(histstore::KEEP[hk >= 0 && hk < histstore::KEEP_COUNT ? hk : histstore::KEEP_DEFAULT]);
|
|
int lc = lvport::loadLiveCap();
|
|
mapview::s_live_cache.setLimit((uint64_t)mapview::LIVE_CAP_MB[lc >= 0 && lc < mapview::LIVE_CAP_COUNT ? lc : mapview::LIVE_CAP_DEFAULT] << 20);
|
|
s_archive.restore(_core->history);
|
|
_core->history.setArchive(&s_archive);
|
|
}
|
|
trailJournalRestore(); // a trail cut off by a restart or a flat battery
|
|
_tap_wake = lvport::loadTapWake();
|
|
msgtext::seedQuick(node_prefs); // "OK" in quick message 1 on first boot
|
|
_nearby = new NearbyModel();
|
|
_nearby->bindModel(_core, node_prefs);
|
|
_scan = new NearbyModel();
|
|
_scan->bindModel(_core, node_prefs);
|
|
_scan->setSource(NearbyModel::SRC_SCAN);
|
|
|
|
#ifdef PIN_USER_BTN
|
|
user_btn.begin();
|
|
#endif
|
|
#ifdef PIN_BUZZER
|
|
soundctl::applyMode(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime());
|
|
_buzzer.setVolume(_prefs ? _prefs->buzzer_volume : 4);
|
|
_buzzer.begin(); // plays the startup sound unless muted
|
|
#endif
|
|
|
|
lv_init();
|
|
lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); });
|
|
if (!lvport::begin()) {
|
|
Serial.println("ui-lvgl: no memory for display buffers");
|
|
return;
|
|
}
|
|
theme::setAccent(lvport::loadAccent());
|
|
theme::setLight(lvport::loadLight());
|
|
theme::install(lv_display_get_default());
|
|
lv_display_add_event_cb(lv_display_get_default(), [](lv_event_t*) { rowsFitPending(); }, LV_EVENT_REFR_START, NULL);
|
|
|
|
buildStatusBar();
|
|
quickPanelBuild(); // QuickPanel.h: ready before the first pull
|
|
applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set
|
|
{ char c[24]; lvport::crashSummary(c, sizeof(c)); } // the dump in flash read now (~45 ms), not on the first Diagnostics
|
|
showHome();
|
|
if (pinSet()) lockScreen(); // a reboot doesn't get round the PIN
|
|
showSplash(); // over both; fades out by itself
|
|
}
|
|
|
|
MyMesh::Listener* UITask::meshListener() { return _core; }
|
|
|
|
// The USER button's hold: sound on / off (leaves Auto, as the Sound page's
|
|
// On / Off would).
|
|
void UITask::toggleMute() {
|
|
#ifdef PIN_BUZZER
|
|
if (!_prefs) return;
|
|
bool on = _buzzer.isQuiet(); // muted now, however (mode, Auto, quiet hours): sound on
|
|
soundctl::setMode(_prefs, _buzzer, on ? soundctl::MODE_ON : soundctl::MODE_OFF, isClientConnected());
|
|
prefsSave();
|
|
if (on) _buzzer.playForced(soundctl::MEL_VOLUME);
|
|
if (!_asleep) { showToast(on ? "Sound on" : "Sound off", 1200); refreshStatusBar(); }
|
|
#endif
|
|
}
|
|
|
|
static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-off cap
|
|
|
|
#include "PerfTest.h" // -D UI_PERF_TEST only: draw profiles and control timing
|
|
|
|
|
|
void UITask::loop() {
|
|
pollConnection();
|
|
drainCoreEvents();
|
|
sampleHistory();
|
|
|
|
// USER (BOOT, side) button: Home's clock page; held and let go, mutes / unmutes, also with the
|
|
// screen off, which it doesn't wake. WAKE (top) button: screen off / on.
|
|
// Either one silences a ringing alarm first. Held in the first 8 s (once the
|
|
// screen is up -- held at power-on it's the ESP32's download mode): CLI rescue.
|
|
bool btn_click = false, btn_hold = false, wake_press = false;
|
|
#ifdef PIN_USER_BTN
|
|
int ev = user_btn.check();
|
|
btn_click = ev == BUTTON_EVENT_CLICK;
|
|
btn_hold = ev == BUTTON_EVENT_LONG_PRESS;
|
|
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
btn_click = s_sim_btn_click;
|
|
btn_hold = s_sim_btn_hold;
|
|
wake_press = s_sim_wake;
|
|
s_sim_btn_click = s_sim_btn_hold = s_sim_wake = false;
|
|
#endif
|
|
#if defined(SEEED_WIO_TRACKER_L2)
|
|
if ((int32_t)(millis() - _next_wake_poll_ms) >= 0) { // an I2C read on the expander
|
|
_next_wake_poll_ms = millis() + 50;
|
|
bool down = board.readWakeButton();
|
|
wake_press = down && !_wake_down;
|
|
_wake_down = down;
|
|
}
|
|
#endif
|
|
// Side button held + top button: a screenshot (and neither does its own thing).
|
|
// A long hold only arms the mute, which happens on letting go -- so holding
|
|
// the side button a bit long before the top one doesn't mute as well.
|
|
static bool mute_armed = false;
|
|
bool shot = false, mute = false;
|
|
#ifdef PIN_USER_BTN
|
|
bool side_down = user_btn.isPressed();
|
|
if (wake_press && side_down) { user_btn.cancelClick(); shot = true; mute_armed = false; }
|
|
if (mute_armed && !side_down) { mute_armed = false; mute = true; }
|
|
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
shot = s_sim_shot;
|
|
s_sim_shot = false;
|
|
if (mute_armed) { mute_armed = false; mute = true; } // the page sends the hold on release
|
|
#endif
|
|
if (shot) { if (!_asleep) takeScreenshot(); }
|
|
else if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing();
|
|
else if (wake_press) { if (_asleep) wake(); else sleep(); }
|
|
else if (btn_hold && millis() - _ui_started_ms < 8000) { // held in the first 8 s: CLI rescue, as on the L1
|
|
the_mesh.enterCLIRescue();
|
|
showToast("CLI rescue on the USB serial", 4000);
|
|
}
|
|
else if (btn_hold) mute_armed = true;
|
|
else if (mute) toggleMute();
|
|
else if (btn_click) { // the side button doesn't wake: pockets
|
|
if (flashlightOn()) flashlightClose();
|
|
else if (quickPanelOpen()) quickPanelClose(true);
|
|
else if (!_asleep && !locked()) goHome();
|
|
}
|
|
|
|
if (_asleep) {
|
|
// Tap to wake (off: the top button only). An I2C read on the touch panel.
|
|
if (_tap_wake && (int32_t)(millis() - _next_touch_poll_ms) >= 0) {
|
|
_next_touch_poll_ms = millis() + 50;
|
|
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
|
|
}
|
|
} else {
|
|
if (lv_display_get_inactive_time(NULL) < 1000) the_mesh.noteUserInput();
|
|
// Locked, nobody's using it: the lock screen goes dark after LOCK_OFF_MS
|
|
// even with auto-off at Never (a woken-up pocket, a boot PIN, a message).
|
|
uint32_t aoff = autoOffMillis();
|
|
if (locked() && (aoff == 0 || aoff > LOCK_OFF_MS)) aoff = LOCK_OFF_MS;
|
|
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy() && !flashlightOn()) sleep();
|
|
}
|
|
|
|
_core->loop();
|
|
drainCoreEvents();
|
|
prefsFlush();
|
|
usbPoll();
|
|
#if defined(UI_HEAP_REPORT) && defined(ESP32)
|
|
// -D UI_HEAP_REPORT: internal / PSRAM heap once, 20 s after boot.
|
|
static bool heap_done = false;
|
|
if (!heap_done && millis() > 20000) {
|
|
heap_done = true;
|
|
Serial.printf("HEAP internal free %u largest %u min %u | psram free %u | idf %s\n",
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL),
|
|
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), esp_get_idf_version());
|
|
}
|
|
#endif
|
|
#if defined(UI_PERF_TEST) && defined(ESP32)
|
|
perfWalk(); // PerfTest.h
|
|
#endif
|
|
#ifdef PIN_BUZZER
|
|
_buzzer.loop();
|
|
if (soundctl::tick(_prefs, _buzzer, isClientConnected(), rtc_clock.getCurrentTime()) && !_asleep) refreshStatusBar();
|
|
// The alarm melody repeats until dismissed or the ring window ends.
|
|
if (_core->clock.isRinging() && !_buzzer.isPlaying()) playMelody(soundctl::MEL_ALARM);
|
|
#endif
|
|
checkLowBattery();
|
|
mapDownloadTick(); // a map download keeps running on any screen, and asleep
|
|
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
|
|
_next_trackback_ms = millis() + 1000;
|
|
navPollTrackBack();
|
|
}
|
|
|
|
uint32_t lv_next = 0;
|
|
if (!_asleep) {
|
|
if ((int32_t)(millis() - _next_status_ms) >= 0) {
|
|
_next_status_ms = millis() + 1000;
|
|
refreshStatusBar();
|
|
refreshLock();
|
|
if (_screen == SCR_HOME) refreshHome();
|
|
if (s_chats_stale && !_nav_overlay) refreshChats(); // a row was held when it came
|
|
refreshDiag();
|
|
refreshCompass();
|
|
refreshGps();
|
|
refreshBattCurve();
|
|
if (quickPanelOpen()) quickPanelRefresh();
|
|
}
|
|
if (locked()) lockPoll();
|
|
else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); }
|
|
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);
|
|
if (_scanning && (int32_t)(millis() - _scan_until_ms) >= 0) _scanning = false;
|
|
if (_screen == SCR_NEARBY) {
|
|
refreshNearbyList();
|
|
if (_scan_overlay) refreshScanPopup();
|
|
} else {
|
|
refreshNode();
|
|
}
|
|
}
|
|
fillTick();
|
|
if (_screen == SCR_MAP) mapLoop();
|
|
if (_screen == SCR_WIFI) pollWifiScan();
|
|
if (_screen == SCR_STORAGE) pollStorage();
|
|
if (_screen == SCR_ADMIN) adminPoll();
|
|
if (_screen == SCR_ROUTE && (int32_t)(millis() - _next_route_ms) >= 0) {
|
|
_next_route_ms = millis() + 200;
|
|
routePoll();
|
|
}
|
|
roomPoll();
|
|
if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) {
|
|
_next_clock_ms = millis() + 100; // stopwatch tenths
|
|
refreshClock();
|
|
}
|
|
if (_screen == SCR_MELODY && (int32_t)(millis() - _next_clock_ms) >= 0) {
|
|
_next_clock_ms = millis() + 30; // the playing note's highlight keeps up
|
|
refreshMelody();
|
|
}
|
|
if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) {
|
|
_next_thread_check_ms = millis() + 500;
|
|
uint32_t sig = threadSignature();
|
|
if (_thread_dirty || sig != _thread_sig) refreshThread();
|
|
else if ((int32_t)(millis() - _next_thread_ages_ms) >= 0) refreshThreadAges();
|
|
messagePopupTick();
|
|
}
|
|
lvport::cpuSlow(!cpuNeeded());
|
|
lv_next = lv_timer_handler();
|
|
}
|
|
lvport::idle(idleMillis(lv_next));
|
|
}
|
|
|
|
// How long loop() may sleep: nothing is due before then. Not at all while the
|
|
// mesh has packets queued; briefly while a melody plays (its notes are timed
|
|
// here) or frames to or from the app wait (read one a pass, sent spaced out).
|
|
// A frame arriving from the app over BLE wakes the loop (serialFrameArrived).
|
|
uint32_t UITask::idleMillis(uint32_t lv_next) {
|
|
if (the_mesh.hasPendingWork()) return 0;
|
|
#ifdef PIN_BUZZER
|
|
if (_buzzer.isPlaying()) return 1;
|
|
#endif
|
|
if (isClientConnected() && _serial->hasPendingFrames()) return 2;
|
|
if (_asleep) return 50; // as often as the buttons and tap to wake are polled
|
|
return lv_next < 10 ? lv_next : 10; // LVGL's next timer: a refresh, an animation, input
|
|
}
|
|
|
|
void UITask::shutdown(bool restart) {
|
|
s_prefs_dirty_ms = s_prefs_save_at = 0;
|
|
saveBeforeShutdown();
|
|
if (!trailJournalTick(true)) _core->trail.onShutdown(); // no card: the internal slot, if enabled
|
|
#ifdef PIN_BUZZER
|
|
_buzzer.shutdown(); // the goodbye sound, unless muted
|
|
#ifndef SIM_PLATFORM // the sim's single thread would freeze the page
|
|
for (uint32_t t0 = millis(); _buzzer.isPlaying() && millis() - t0 < 2500; ) { _buzzer.loop(); delay(10); }
|
|
#endif
|
|
#endif
|
|
powerDown(restart, _display, _sensors);
|
|
}
|
|
|
|
// Settings > Power > Battery shutdown (NodePrefs::low_batt_mv), as
|
|
// ui-new does it: a smoothed reading every 8 s, never while on USB power.
|
|
void UITask::checkLowBattery() {
|
|
if ((int32_t)(millis() - _next_batt_ms) < 0) return;
|
|
_next_batt_ms = millis() + 8000;
|
|
uint16_t raw = getBattMilliVolts();
|
|
if (raw > 0) _batt_mv = battEma(_batt_mv, raw);
|
|
if (!lowBatteryShutdownDue(_batt_mv, _prefs)) return;
|
|
wake();
|
|
showToast("Low battery - shutting down", 3000);
|
|
lv_timer_handler();
|
|
delay(2000);
|
|
shutdown();
|
|
}
|
|
|
|
uint32_t UITask::autoOffMillis() const {
|
|
if (!_prefs || _prefs->auto_off_secs == 0) return 0;
|
|
return (uint32_t)_prefs->auto_off_secs * 1000UL;
|
|
}
|
|
|
|
void UITask::sleep() {
|
|
if (_asleep) return;
|
|
_asleep = true;
|
|
quickPanelClose(false);
|
|
flashlightClose();
|
|
if (_display) _display->turnOff();
|
|
prefsSaveSoon(0); // nothing to stall now
|
|
lvport::powerSave(true, _tap_wake);
|
|
if ((_prefs && _prefs->auto_lock) || pinSet()) lockScreen(); // Lock screen, or a screen PIN
|
|
}
|
|
|
|
void UITask::wake() {
|
|
lv_display_trigger_activity(NULL);
|
|
if (!_asleep) return;
|
|
_asleep = false;
|
|
lvport::powerSave(false, _tap_wake);
|
|
if (_display) _display->turnOn(); // dark until the frame below is on the panel
|
|
refreshStatusBar();
|
|
if (_screen == SCR_HOME) refreshHome();
|
|
if (_screen == SCR_THREAD) { if (locked()) refreshThread(); else reopenThreadIfUnread(); } // locked: on unlocking
|
|
lv_obj_invalidate(lv_screen_active());
|
|
lv_refr_now(NULL);
|
|
lvport::backlightFadeIn();
|
|
}
|
|
|
|
// ── Core events ───────────────────────────────────────────────────────────────
|
|
|
|
void UITask::drainCoreEvents() {
|
|
UiEvent ev;
|
|
while (_core->events.pop(ev)) {
|
|
switch (ev.type) {
|
|
case UiEventType::MessageArrived:
|
|
onMessageArrived(ev);
|
|
break;
|
|
case UiEventType::ClockAlert:
|
|
showRing(ev.text);
|
|
playMelody(soundctl::MEL_ALARM);
|
|
break;
|
|
case UiEventType::ClockRingEnded:
|
|
stopMelody();
|
|
hideRing();
|
|
break;
|
|
case UiEventType::LiveShareEnded:
|
|
showToast("Live share ended");
|
|
break;
|
|
case UiEventType::LocatorCrossed:
|
|
showToast(ev.text, 3000);
|
|
#ifdef PIN_BUZZER
|
|
if (!_buzzer.isQuiet()) playMelody(ev.flag ? soundctl::MEL_ARRIVE : soundctl::MEL_LEAVE);
|
|
#endif
|
|
break;
|
|
case UiEventType::LocatorBeep: // Settings > Map > Proximity beeper
|
|
playMelody(soundctl::MEL_TICK);
|
|
break;
|
|
case UiEventType::AdvertHeard:
|
|
notify(ev.flag ? UIEventType::advertReceivedFlood : UIEventType::advertReceivedZeroHop);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Message banner ──
|
|
// An incoming message slides down from under the status bar: an icon, the
|
|
// sender (or channel) and the start of the text. A tap opens the conversation
|
|
// (not while the screen is locked), a swipe up puts it away, and it goes by
|
|
// itself after BANNER_MS. The toast at the bottom stays for everything else.
|
|
static lv_obj_t* s_banner = nullptr;
|
|
static lv_obj_t* s_banner_icon = nullptr;
|
|
static lv_obj_t* s_banner_title = nullptr;
|
|
static lv_obj_t* s_banner_text = nullptr;
|
|
static lv_timer_t* s_banner_timer = nullptr;
|
|
static UIEventType s_banner_kind = UIEventType::none;
|
|
static int16_t s_banner_ch = -1;
|
|
static uint8_t s_banner_key[4];
|
|
static const uint32_t BANNER_MS = 4000;
|
|
|
|
static void bannerHidden(lv_anim_t* a) { lv_obj_add_flag((lv_obj_t*)a->var, LV_OBJ_FLAG_HIDDEN); }
|
|
static void bannerHide() {
|
|
if (!s_banner || lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN)) return;
|
|
if (s_banner_timer) lv_timer_pause(s_banner_timer);
|
|
lv_anim_delete(s_banner, NULL);
|
|
int32_t off = -(lv_obj_get_height(s_banner) + theme::STATUS_H);
|
|
anim::run(s_banner, anim::setTy, lv_obj_get_style_translate_y(s_banner, LV_PART_MAIN), off, anim::OUT_MS, bannerHidden);
|
|
}
|
|
static void bannerTimerCb(lv_timer_t* t) { (void)t; bannerHide(); }
|
|
static void onBanner(lv_event_t* e) {
|
|
if (lv_event_get_code(e) == LV_EVENT_GESTURE) {
|
|
if (lv_indev_get_gesture_dir(lv_indev_active()) == LV_DIR_TOP) bannerHide();
|
|
return;
|
|
}
|
|
bannerHide();
|
|
s_ui->bannerOpen();
|
|
}
|
|
|
|
// Built once, behind the status bar (made just after it on the top layer).
|
|
static void bannerBuild() {
|
|
const int32_t w = lv_display_get_horizontal_resolution(NULL);
|
|
s_banner = lv_obj_create(lv_layer_top());
|
|
lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
|
lv_obj_set_size(s_banner, w - 12, LV_SIZE_CONTENT);
|
|
lv_obj_align(s_banner, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 2);
|
|
// Raised off the page: lighter than it on the dark theme, white on the light.
|
|
lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::s_light ? theme::SURFACE : theme::SURFACE_2), 0);
|
|
lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::s_light ? theme::SURFACE_2 : theme::SURFACE), LV_STATE_PRESSED);
|
|
lv_obj_set_style_border_color(s_banner, lv_color_hex(theme::EDGE), 0);
|
|
lv_obj_set_style_border_width(s_banner, 1, 0);
|
|
lv_obj_set_style_radius(s_banner, theme::RADIUS, 0);
|
|
lv_obj_set_style_shadow_width(s_banner, 12, 0);
|
|
lv_obj_set_style_shadow_color(s_banner, lv_color_hex(0x000000), 0);
|
|
lv_obj_set_style_shadow_opa(s_banner, theme::s_light ? LV_OPA_20 : LV_OPA_60, 0);
|
|
lv_obj_set_style_pad_all(s_banner, 8, 0);
|
|
lv_obj_set_style_pad_column(s_banner, 10, 0);
|
|
lv_obj_set_flex_flow(s_banner, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(s_banner, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_add_event_cb(s_banner, onBanner, LV_EVENT_CLICKED, NULL);
|
|
lv_obj_add_event_cb(s_banner, onBanner, LV_EVENT_GESTURE, NULL);
|
|
s_banner_icon = label(s_banner, LV_SYMBOL_ENVELOPE, THEME_FONT_TITLE, theme::ACCENT);
|
|
lv_obj_set_width(s_banner_icon, 20); // the text lines up whichever icon
|
|
lv_obj_set_style_text_align(s_banner_icon, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_obj_t* col = flexBox(s_banner, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_width(col, 1);
|
|
lv_obj_set_flex_grow(col, 1);
|
|
lv_obj_set_style_pad_row(col, 1, 0);
|
|
s_banner_title = label(col, "", THEME_FONT_BODY, theme::TEXT);
|
|
lv_label_set_long_mode(s_banner_title, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(s_banner_title, LV_PCT(100));
|
|
s_banner_text = label(col, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
lv_label_set_long_mode(s_banner_text, LV_LABEL_LONG_DOT); // (LONG_DOT cuts at the max height)
|
|
lv_obj_set_width(s_banner_text, LV_PCT(100));
|
|
lv_obj_set_style_max_height(s_banner_text, lv_font_get_line_height(THEME_FONT_SMALL) * 2, 0); // two lines, then "..."
|
|
lv_obj_add_flag(s_banner, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
|
|
static void bannerShow(const char* icon, const char* title, const char* text) {
|
|
if (!s_banner) return;
|
|
lv_label_set_text(s_banner_icon, icon);
|
|
lv_label_set_text(s_banner_title, title);
|
|
lv_label_set_text(s_banner_text, text);
|
|
bool shown = !lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN);
|
|
lv_anim_delete(s_banner, NULL);
|
|
lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_HIDDEN);
|
|
if (!shown) { // a new one while it's up just changes the text
|
|
lv_obj_align(s_banner, LV_ALIGN_TOP_MID, 0, belowTopBar() + 2);
|
|
layoutNow(s_banner);
|
|
anim::run(s_banner, anim::setTy, -(lv_obj_get_height(s_banner) + theme::STATUS_H), 0, anim::SCREEN_MS);
|
|
} else {
|
|
lv_obj_set_style_translate_y(s_banner, 0, 0);
|
|
}
|
|
if (!s_banner_timer) s_banner_timer = lv_timer_create(bannerTimerCb, BANNER_MS, NULL);
|
|
lv_timer_set_period(s_banner_timer, BANNER_MS);
|
|
lv_timer_reset(s_banner_timer);
|
|
lv_timer_resume(s_banner_timer);
|
|
}
|
|
|
|
void UITask::bannerOpen() {
|
|
if (locked()) return; // the lock screen first
|
|
if (s_banner_kind == UIEventType::channelMessage && s_banner_ch >= 0) openChannel((uint8_t)s_banner_ch);
|
|
else if (s_banner_kind == UIEventType::contactMessage) openDM(s_banner_key);
|
|
else showChats(); // a room post: the list
|
|
}
|
|
|
|
void UITask::onMessageArrived(const UiEvent& ev) {
|
|
if (ev.kind == UIEventType::contactMessage && ev.flag) { // the sender's alert / melody overrides
|
|
memcpy(_notif_dm_prefix, ev.key, 4);
|
|
_notif_dm_valid = true;
|
|
}
|
|
if (ev.kind == UIEventType::channelMessage) _notif_ch_idx = ev.idx;
|
|
notify(ev.kind);
|
|
// The banner: the name from the event, the text from the newest history entry.
|
|
const char* text = "";
|
|
const char* icon = LV_SYMBOL_ENVELOPE;
|
|
s_banner_kind = ev.kind;
|
|
s_banner_ch = ev.idx;
|
|
memcpy(s_banner_key, ev.key, 4);
|
|
if (ev.kind == UIEventType::channelMessage) {
|
|
icon = "#";
|
|
int pos = ev.idx >= 0 ? _core->history.histEntryForChannel(ev.idx, 0) : -1;
|
|
if (pos >= 0) text = _core->history.chAtPos(pos).text;
|
|
} else if (ev.kind == UIEventType::contactMessage) {
|
|
int pos = _core->history.dmHistEntryForContact(ev.key, 0);
|
|
if (pos >= 0) text = _core->history.dmAtPos(pos).text;
|
|
} else {
|
|
icon = LV_SYMBOL_LIST; // a room
|
|
}
|
|
// Wake for the message unless an app is already showing it, or the user
|
|
// turned message-wake off.
|
|
bool wake_disabled = _prefs && (!_prefs->msg_wake || inQuietHours(*_prefs, rtc_clock.getCurrentTime())); // quiet hours: dark too
|
|
if (_asleep && !wake_disabled && !isClientConnected()) wake();
|
|
else if (!_asleep) lv_display_trigger_activity(NULL);
|
|
bool open_here = _screen == SCR_THREAD &&
|
|
(ev.kind == UIEventType::channelMessage ? _thread_is_channel && ev.idx == _thread_channel
|
|
: !_thread_is_channel && memcmp(_thread_key, ev.key, 4) == 0);
|
|
bool banner = !_prefs || _prefs->msg_alert != NodePrefs::MSG_ALERT_OFF;
|
|
if (!open_here && banner) bannerShow(icon, ev.text, text); // not over the very conversation it's in
|
|
if (_screen == SCR_CHATS && !_nav_overlay) refreshChats(); // new unread counts (not under an open popup)
|
|
}
|
|
|
|
// On screen: a conversation left open under the lock or a dark display isn't read.
|
|
bool UITask::threadInView() const {
|
|
return !_asleep && !locked() && _screen == SCR_THREAD;
|
|
}
|
|
|
|
bool UITask::isViewingChannel(uint8_t channel_idx) {
|
|
return threadInView() && _thread_is_channel && _thread_channel == channel_idx;
|
|
}
|
|
|
|
bool UITask::isViewingDM(const uint8_t* pub_key) {
|
|
return threadInView() && !_thread_is_channel && memcmp(_thread_key, pub_key, 4) == 0;
|
|
}
|
|
|
|
// ── Status bar + toast (top layer, over every screen) ─────────────────────────
|
|
|
|
static void onStatusBarTouch(lv_event_t* e); // QuickPanel.h
|
|
|
|
void UITask::buildStatusBar() {
|
|
// The banner and the toast first: the status bar, made after them, covers
|
|
// them as they slide in and out from under it.
|
|
bannerBuild();
|
|
_toast = lv_obj_create(lv_layer_top());
|
|
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_size(_toast, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
|
// On the light theme a dark pill, as a phone's: a light one was lost on the page.
|
|
lv_obj_set_style_bg_color(_toast, lv_color_hex(theme::s_light ? theme::TEXT : theme::SURFACE_2), 0);
|
|
lv_obj_set_style_border_color(_toast, lv_color_hex(theme::EDGE), 0);
|
|
lv_obj_set_style_border_width(_toast, theme::s_light ? 0 : 1, 0);
|
|
lv_obj_set_style_radius(_toast, theme::RADIUS, 0);
|
|
lv_obj_set_style_pad_hor(_toast, 12, 0);
|
|
lv_obj_set_style_pad_ver(_toast, 6, 0);
|
|
lv_obj_align(_toast, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // slides down from under the status bar
|
|
lv_obj_t* tl = label(_toast, "", THEME_FONT_BODY, theme::s_light ? theme::SURFACE : theme::TEXT);
|
|
lv_label_set_long_mode(tl, LV_LABEL_LONG_WRAP); // long texts wrap instead of running off the screen
|
|
lv_obj_set_style_max_width(tl, lv_display_get_horizontal_resolution(NULL) - 40, 0);
|
|
lv_obj_set_style_text_align(tl, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_t* bar = lv_obj_create(lv_layer_top());
|
|
s_status_bar = bar;
|
|
styleOpaque(bar, theme::BG);
|
|
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_add_flag(bar, LV_OBJ_FLAG_PRESS_LOCK); // pulled down past it: still its touch (the quick panel)
|
|
lv_obj_remove_flag(bar, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
|
lv_obj_add_event_cb(bar, onStatusBarTouch, LV_EVENT_ALL, NULL);
|
|
lv_obj_set_size(bar, LV_PCT(100), theme::STATUS_H);
|
|
lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
|
|
lv_obj_set_style_pad_hor(bar, theme::PAD, 0);
|
|
|
|
_status_time = label(bar, "--:--", THEME_FONT_SMALL, theme::TEXT);
|
|
lv_obj_align(_status_time, LV_ALIGN_LEFT_MID, 0, 0);
|
|
_status_batt = label(bar, "", THEME_FONT_ICONS, theme::TEXT_MUTED);
|
|
lv_obj_align(_status_batt, LV_ALIGN_RIGHT_MID, 0, 0);
|
|
_status_chg = label(bar, LV_SYMBOL_CHARGE, THEME_FONT_ICONS, theme::OK);
|
|
lv_obj_add_flag(_status_chg, LV_OBJ_FLAG_HIDDEN);
|
|
_status_icons = lv_obj_create(bar);
|
|
lv_obj_remove_style_all(_status_icons);
|
|
lv_obj_remove_flag(_status_icons, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_size(_status_icons, 200, LV_PCT(100)); // fixed: packed to its right edge
|
|
lv_obj_set_flex_flow(_status_icons, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(_status_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_column(_status_icons, 2, 0);
|
|
|
|
|
|
refreshStatusBar();
|
|
}
|
|
|
|
// lv_obj_align_to(o, base, LV_ALIGN_OUT_LEFT_MID, dx, 0), `base` a sibling,
|
|
// for what's refreshed every second: LVGL restyles the box on every call, so
|
|
// it redraws moved or not -- here it's only moved.
|
|
static void placeLeftOf(lv_obj_t* o, lv_obj_t* base, int32_t dx) {
|
|
layoutNow(o);
|
|
int32_t x = lv_obj_get_x(base) - lv_obj_get_width(o) + dx;
|
|
int32_t y = lv_obj_get_y(base) + (lv_obj_get_height(base) - lv_obj_get_height(o)) / 2;
|
|
if (lv_obj_get_style_align(o, LV_PART_MAIN) != LV_ALIGN_TOP_LEFT || lv_obj_get_x(o) != x || lv_obj_get_y(o) != y)
|
|
lv_obj_align_to(o, base, LV_ALIGN_OUT_LEFT_MID, dx, 0);
|
|
}
|
|
|
|
void UITask::refreshStatusBar() {
|
|
if (!_status_time) return;
|
|
struct tm ti;
|
|
if (localTime(_prefs, ti)) {
|
|
char clk[12];
|
|
fmtClock(clk, sizeof(clk), ti, _prefs, true);
|
|
setText(_status_time, clk);
|
|
} else {
|
|
setText(_status_time, "--:--");
|
|
}
|
|
|
|
// Battery: the icon, then % or volts per Settings > Power > Battery display. The
|
|
// smoothed reading (checkLowBattery): an ADC read blocks the loop for 10 ms.
|
|
uint16_t mv = _batt_mv ? _batt_mv : getBattMilliVolts();
|
|
int pct = battery::percent(mv, _prefs ? _prefs->low_batt_mv : 0);
|
|
const char* batt = pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3
|
|
: pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY;
|
|
char level[12] = "";
|
|
switch (battery::mode(_prefs ? _prefs->batt_display_mode : 0)) {
|
|
case battery::PERCENT: snprintf(level, sizeof(level), " %d%%", pct); break;
|
|
case battery::VOLTAGE: snprintf(level, sizeof(level), " %u.%02u V", mv / 1000, (mv % 1000) / 10); break;
|
|
default: break;
|
|
}
|
|
setTextFmt(_status_batt, "%s%s", batt, level);
|
|
const bool chg = _board->isExternalPowered();
|
|
if (_hdr_clock) { // the title bar's: the time over the battery, green on the charger
|
|
setText(_hdr_time, lv_label_get_text(_status_time));
|
|
setTextFmt(_hdr_batt, "%s%s", batt, level);
|
|
setTextColor(_hdr_batt, chg ? theme::OK : theme::TEXT_MUTED);
|
|
layoutNow(_hdr_clock);
|
|
const int32_t w = lv_obj_get_width(_hdr_clock); // the row stops short of it
|
|
if (lv_obj_get_style_pad_right(_header, LV_PART_MAIN) != w) {
|
|
lv_obj_set_style_pad_right(_header, w, 0);
|
|
lv_obj_align(_hdr_clock, LV_ALIGN_RIGHT_MID, w, 0); // aligned inside the padding: out into it
|
|
}
|
|
}
|
|
lv_obj_t* left_of = _status_batt; // the icons pack up to this
|
|
if (chg) {
|
|
lv_obj_remove_flag(_status_chg, LV_OBJ_FLAG_HIDDEN);
|
|
placeLeftOf(_status_chg, _status_batt, -2);
|
|
left_of = _status_chg;
|
|
} else lv_obj_add_flag(_status_chg, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
// Status icons, as the original's status bar (ui-new): Bluetooth (accent
|
|
// when the app is connected), WiFi (when switched on, accent connected), GPS (green with a fix), the alarm, mute, then
|
|
// the modes that keep running in the background -- auto-advert, trail, live
|
|
// share, repeater, arrival alert -- in the accent colour. Right to left in
|
|
// that order, next to the battery.
|
|
struct Icon { const char* sym; uint32_t col; };
|
|
Icon icons[11];
|
|
int n = 0;
|
|
if (isSerialEnabled()) icons[n++] = { LV_SYMBOL_BLUETOOTH, hasConnection() ? theme::ACCENT : theme::TEXT_MUTED };
|
|
if (lvport::wifiAllowed()) // WiFi switched on: accent while connected (map tiles, update)
|
|
icons[n++] = { LV_SYMBOL_WIFI, lvport::netRadio() == lvport::NET_UP ? theme::ACCENT : theme::TEXT_MUTED };
|
|
int32_t lat, lon;
|
|
if (_core->gpsEnabled()) { // switched off, the module still reports its last fix as valid
|
|
bool fix = _core->course.currentLocation(lat, lon);
|
|
icons[n++] = { LV_SYMBOL_GPS, fix ? theme::OK : theme::TEXT_MUTED };
|
|
}
|
|
if (_prefs && _prefs->alarm_on) icons[n++] = { LV_SYMBOL_BELL, theme::TEXT_MUTED };
|
|
#ifdef PIN_BUZZER
|
|
if (_buzzer.isQuiet()) icons[n++] = { UI_SYMBOL_MUTE, theme::TEXT_MUTED };
|
|
#endif
|
|
if (_prefs && _prefs->advert_auto_interval_sec > 0) icons[n++] = { UI_SYMBOL_RADIO, theme::ACCENT };
|
|
if (_core->trail.isActive()) icons[n++] = { UI_SYMBOL_ROUTE, theme::ACCENT };
|
|
if (_prefs && _prefs->loc_share_enabled) icons[n++] = { UI_SYMBOL_PIN, theme::ACCENT };
|
|
if (_prefs && _prefs->client_repeat) icons[n++] = { LV_SYMBOL_LOOP, theme::ACCENT };
|
|
if (_prefs && _prefs->locator_enabled && _prefs->locator_has_target) icons[n++] = { UI_SYMBOL_FLAG, theme::ACCENT };
|
|
char sig[sizeof(_status_sig)];
|
|
int o = 0;
|
|
for (int i = 0; i < n && o < (int)sizeof(sig) - 12; i++) o += snprintf(sig + o, sizeof(sig) - o, "%s%lx", icons[i].sym, (unsigned long)icons[i].col);
|
|
sig[o] = '\0';
|
|
if (strcmp(sig, _status_sig) != 0) { // rebuilt only when something changed
|
|
strcpy(_status_sig, sig);
|
|
lv_obj_clean(_status_icons);
|
|
for (int i = n - 1; i >= 0; i--) // the icon font gives each the same size and cell
|
|
label(_status_icons, icons[i].sym, THEME_FONT_ICONS, icons[i].col);
|
|
}
|
|
placeLeftOf(_status_icons, left_of, -5);
|
|
}
|
|
|
|
// The status bar is up over a screen without a title bar (Home, the map, a
|
|
// conversation typing), and over the quick panel and the lock whatever the
|
|
// screen; a title bar carries the time and battery itself.
|
|
void UITask::statusBarSync() {
|
|
if (!s_status_bar) return;
|
|
const bool titled = _header && !lv_obj_has_flag(_header, LV_OBJ_FLAG_HIDDEN);
|
|
const bool hide = titled && !s_sb_panel && !s_sb_lock;
|
|
if (hide != lv_obj_has_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN)) lv_obj_set_flag(s_status_bar, LV_OBJ_FLAG_HIDDEN, hide);
|
|
}
|
|
|
|
void UITask::setGps(bool on) {
|
|
if (!_core->setGpsEnabled(on, false)) { showToast("No GPS on this device"); return; }
|
|
prefsSave(); // written on leaving the screen, as the other switches
|
|
refreshStatusBar();
|
|
}
|
|
|
|
void UITask::botSetGPS(bool on) { if (_core->setGpsEnabled(on)) refreshStatusBar(); }
|
|
|
|
bool UITask::ensureGps() {
|
|
int32_t lat, lon;
|
|
if (_core->gpsAvailable() && !_core->gpsEnabled()) { setGps(true); return false; } // off, its last fix still reads valid
|
|
if (_core->course.currentLocation(lat, lon)) return true;
|
|
showToast("Waiting for a GPS fix");
|
|
return false;
|
|
}
|
|
|
|
// One persistent timer, paused between toasts: hides the toast when it fires.
|
|
static void toastHidden(lv_anim_t* a) { lv_obj_add_flag((lv_obj_t*)a->var, LV_OBJ_FLAG_HIDDEN); }
|
|
static void toastTimerCb(lv_timer_t* t) { // back up under the status bar
|
|
lv_obj_t* o = (lv_obj_t*)lv_timer_get_user_data(t);
|
|
anim::run(o, anim::setTy, 0, -(lv_obj_get_y(o) + lv_obj_get_height(o)), anim::OUT_MS, toastHidden);
|
|
lv_timer_pause(t);
|
|
}
|
|
|
|
// A deliberate restart (the card formatted, the USB drive given back): a full
|
|
// screen saying so -- drawn at once, the loop stops here -- unsaved settings
|
|
// written, then the restart. Without it the screen just froze.
|
|
static void restartScreen(const char* why) {
|
|
lv_obj_t* o = lv_obj_create(lv_layer_top());
|
|
lv_obj_remove_style_all(o);
|
|
lv_obj_set_size(o, LV_PCT(100), LV_PCT(100));
|
|
lv_obj_set_style_bg_color(o, lv_color_hex(theme::BG), 0);
|
|
lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0);
|
|
lv_obj_set_flex_flow(o, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_flex_align(o, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_row(o, 8, 0);
|
|
label(o, LV_SYMBOL_REFRESH, THEME_FONT_LARGE, theme::ACCENT);
|
|
label(o, "Restarting...", THEME_FONT_LARGE, theme::TEXT);
|
|
lv_obj_t* w = label(o, why, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
lv_obj_set_style_text_align(w, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_obj_invalidate(o);
|
|
lv_refr_now(NULL);
|
|
prefsSaveSoon(0);
|
|
prefsFlush();
|
|
delay(1200); // long enough to read
|
|
lvport::restart();
|
|
lv_obj_delete(o); // the sim carries on
|
|
}
|
|
|
|
// The screen as it is, toasts and popups included, re-rendered once into a
|
|
// buffer the flush callback fills (lvport::shotCopy).
|
|
uint16_t* UITask::captureFrame(int& w, int& h) {
|
|
lv_display_t* d = lv_display_get_default();
|
|
w = lv_display_get_horizontal_resolution(d);
|
|
h = lv_display_get_vertical_resolution(d);
|
|
#ifdef ESP32
|
|
uint16_t* px = (uint16_t*)heap_caps_malloc(w * h * 2, MALLOC_CAP_SPIRAM);
|
|
#else
|
|
uint16_t* px = (uint16_t*)malloc(w * h * 2);
|
|
#endif
|
|
if (!px) return nullptr;
|
|
lvport::s_shot = px;
|
|
lv_obj_invalidate(lv_screen_active()); // the whole display, every layer
|
|
lv_refr_now(d);
|
|
lvport::s_shot = nullptr;
|
|
return px;
|
|
}
|
|
|
|
// The screen, written to the card as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp.
|
|
void UITask::takeScreenshot() {
|
|
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
|
|
mkdir("/sdcard", 0777); // the sim's MEMFS may not have it yet (the card: fails, harmless)
|
|
mkdir("/sdcard/screenshots", 0777);
|
|
char path[48];
|
|
struct stat st;
|
|
int n = 1;
|
|
for (; n <= 9999; n++) {
|
|
snprintf(path, sizeof(path), "/sdcard/screenshots/scr_%04d.bmp", n);
|
|
if (stat(path, &st) != 0) break;
|
|
}
|
|
if (n > 9999) { showToast("Screenshots folder full"); return; }
|
|
int W, H;
|
|
uint16_t* px = captureFrame(W, H);
|
|
if (!px) { showToast("Out of memory"); return; }
|
|
|
|
bool ok = false;
|
|
if (FILE* f = fopen(path, "wb")) {
|
|
const uint32_t row = (uint32_t)W * 3, size = 54 + row * H; // W*3 is a multiple of 4 at 320
|
|
uint8_t hdr[54] = {'B', 'M'};
|
|
auto put32 = [&](int at, uint32_t v) { for (int i = 0; i < 4; i++) hdr[at + i] = (uint8_t)(v >> (8 * i)); };
|
|
put32(2, size); put32(10, 54); put32(14, 40); put32(18, W); put32(22, H);
|
|
hdr[26] = 1; hdr[28] = 24; put32(34, row * H);
|
|
ok = fwrite(hdr, 1, 54, f) == 54;
|
|
static PSRAM_BSS uint8_t line[320 * 3]; // not on the loop task's stack
|
|
for (int32_t y = H - 1; ok && y >= 0; y--) { // bottom-up, BGR
|
|
const uint16_t* s = px + y * W;
|
|
for (int32_t x = 0; x < W && x < 320; x++) {
|
|
uint16_t c = s[x];
|
|
uint8_t r = (c >> 11) & 0x1F, g = (c >> 5) & 0x3F, b = c & 0x1F;
|
|
line[x * 3 + 0] = (uint8_t)((b << 3) | (b >> 2));
|
|
line[x * 3 + 1] = (uint8_t)((g << 2) | (g >> 4));
|
|
line[x * 3 + 2] = (uint8_t)((r << 3) | (r >> 2));
|
|
}
|
|
ok = fwrite(line, 1, row, f) == row;
|
|
}
|
|
ok = (fclose(f) == 0) && ok;
|
|
}
|
|
free(px);
|
|
if (!ok) { remove(path); showToast("Couldn't save the screenshot"); return; }
|
|
|
|
// A white frame flashing round the edges, then where it went. Four thin
|
|
// strips, not a full-screen layer: each frame of the fade redraws only
|
|
// them (a whole 320x240 frame per step tore and stuttered on the panel).
|
|
const int32_t T = 6;
|
|
const int32_t strip[4][4] = { { 0, 0, W, T }, { 0, H - T, W, T }, { 0, T, T, H - 2 * T }, { W - T, T, T, H - 2 * T } };
|
|
for (const auto& r : strip) {
|
|
lv_obj_t* f = lv_obj_create(lv_layer_top());
|
|
lv_obj_remove_style_all(f);
|
|
lv_obj_set_pos(f, r[0], r[1]);
|
|
lv_obj_set_size(f, r[2], r[3]);
|
|
lv_obj_set_style_bg_color(f, lv_color_white(), 0);
|
|
lv_obj_remove_flag(f, LV_OBJ_FLAG_CLICKABLE);
|
|
anim::run(f, anim::setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, 400, anim::coverDone);
|
|
}
|
|
char msg[40];
|
|
snprintf(msg, sizeof(msg), "Saved scr_%04d.bmp", n);
|
|
showToast(msg);
|
|
}
|
|
|
|
void UITask::showToast(const char* text, uint32_t ms) {
|
|
if (!_toast) return;
|
|
lv_label_set_text(lv_obj_get_child(_toast, 0), text);
|
|
bool shown = !lv_obj_has_flag(_toast, LV_OBJ_FLAG_HIDDEN);
|
|
lv_anim_delete(_toast, NULL); // on its way out
|
|
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_HIDDEN);
|
|
// Under the message banner while that's up, else right under the top bar.
|
|
int32_t y = belowTopBar() + 4;
|
|
if (s_banner && !lv_obj_has_flag(s_banner, LV_OBJ_FLAG_HIDDEN)) y = lv_obj_get_y(s_banner) + lv_obj_get_height(s_banner) + 4;
|
|
lv_obj_align(_toast, LV_ALIGN_TOP_MID, 0, y);
|
|
layoutNow(_toast);
|
|
if (!shown) anim::run(_toast, anim::setTy, -(y + lv_obj_get_height(_toast)), 0, anim::POP_MS); // a replaced text just changes
|
|
else lv_obj_set_style_translate_y(_toast, 0, 0);
|
|
if (!_toast_timer) _toast_timer = lv_timer_create(toastTimerCb, ms, _toast);
|
|
lv_timer_set_period(_toast_timer, ms);
|
|
lv_timer_reset(_toast_timer);
|
|
lv_timer_resume(_toast_timer);
|
|
}
|
|
|
|
// ── Screens ───────────────────────────────────────────────────────────────────
|
|
|
|
static void onBack(lv_event_t* e) { (void)e; s_ui->back(); }
|
|
|
|
// A fresh screen below the status bar, loaded in place of the current one;
|
|
// returns its content area (flex column). The old screen is deleted
|
|
// asynchronously -- this usually runs from a click on one of its own widgets.
|
|
static uint32_t s_wifi_test_ms = 0; // Settings > WiFi checking a just-saved network (WifiScreen.h)
|
|
|
|
namespace home { static void leave(); } // HomeScreen.h
|
|
|
|
static int s_th_rows_n = 0; // conversation bubbles built (refreshThread)
|
|
|
|
// A screen builds in pieces over several loop passes: what shows first at
|
|
// once, the rest as time allows. A widget costs ~1 ms on the L2 (they live in
|
|
// PSRAM), so 48 nodes, a few hundred contacts or a long settings page built
|
|
// in one go held the screen for up to a second. The pieces queue in order:
|
|
// fill(fn, from, to) makes fn(from) .. fn(to - 1), each from the data as it
|
|
// is then; then(fn, arg) makes its piece at once unless others still wait,
|
|
// keeping its place after them. A new screen drops what is left.
|
|
struct FillJob { void (UITask::*fn)(int); int next, end; };
|
|
static const int FILL_JOBS = 12;
|
|
static FillJob s_fill[FILL_JOBS];
|
|
static int s_fill_n = 0;
|
|
static const uint32_t FILL_BUDGET_US = 25000; // pieces made per loop pass: about a frame's worth
|
|
|
|
void UITask::fill(void (UITask::*fn)(int), int from, int to) {
|
|
if (from >= to) return;
|
|
if (s_fill_n == FILL_JOBS) { while (from < to) (this->*fn)(from++); return; } // no room: now
|
|
s_fill[s_fill_n++] = { fn, from, to };
|
|
}
|
|
|
|
void UITask::then(void (UITask::*fn)(int), int arg) {
|
|
if (s_fill_n) fill(fn, arg, arg + 1);
|
|
else (this->*fn)(arg);
|
|
}
|
|
|
|
// Rows 0 .. n-1 of a list: the first `first` at once, the rest queued.
|
|
void UITask::fillStart(int n, int first, void (UITask::*row)(int)) {
|
|
int now = first < n ? first : n;
|
|
for (int i = 0; i < now; i++) (this->*row)(i);
|
|
fill(row, now, n);
|
|
}
|
|
|
|
void UITask::fillStep() {
|
|
FillJob& j = s_fill[0];
|
|
(this->*j.fn)(j.next++); // may queue more, behind
|
|
if (!s_fill_n) return; // the piece built a new screen: the queue went with the old one
|
|
if (j.next >= j.end) memmove(s_fill, s_fill + 1, --s_fill_n * sizeof(FillJob));
|
|
}
|
|
|
|
void UITask::fillTick() {
|
|
// Not while the screen moves in: its frames came a budget apart (Nodes,
|
|
// 57 rows: 33 ms a frame, up to 65). What it shows first is built already.
|
|
if (!s_fill_n || (_body && lv_anim_get(_body, NULL)) || lv_display_get_screen_prev(NULL)) return;
|
|
uint32_t t0 = micros();
|
|
while (s_fill_n) {
|
|
fillStep();
|
|
if (micros() - t0 >= FILL_BUDGET_US) break;
|
|
}
|
|
}
|
|
|
|
// Everything still queued, now: before scrolling to where the screen was.
|
|
void UITask::fillFlush() { while (s_fill_n) fillStep(); }
|
|
|
|
int UITask::fillPending() const {
|
|
int n = 0;
|
|
for (int k = 0; k < s_fill_n; k++) n += s_fill[k].end - s_fill[k].next;
|
|
return n;
|
|
}
|
|
|
|
#include "NodeViz.h" // the node detail's rose and path (newScreen() drops its card)
|
|
|
|
lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
|
|
quickPanelClose(false);
|
|
s_fill_n = 0; // what was still to be built goes with the screen
|
|
_home_clock = _home_date = _home_unread = nullptr;
|
|
_thread_list = _compose_ta = _keyboard = nullptr;
|
|
s_th_rows_n = 0; // the bubbles went with the screen
|
|
if (_shown_screen == SCR_SETTINGS && _body) s_settings_y = lv_obj_get_scroll_y(_body);
|
|
pickerClose();
|
|
_header = _body = nullptr;
|
|
_nearby_list = _nearby_status = nullptr;
|
|
_node_info = _node_ping = _node_delete_lbl = nullptr;
|
|
nodeviz::s_card = nullptr;
|
|
_scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen
|
|
_map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = _map_center_btn = nullptr;
|
|
_dl_overlay = _dl_info = _dl_sub = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr;
|
|
_nav_bar = _nav_title = _nav_info = _nav_clear = nullptr;
|
|
_nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr;
|
|
_nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr;
|
|
_wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr;
|
|
if ((_wifi_scanning || s_wifi_test_ms) && !wifiInUse()) lvport::netEnd(); // left mid-scan / check: the radio goes off
|
|
_wifi_scanning = false;
|
|
s_wifi_test_ms = 0;
|
|
_ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr;
|
|
for (lv_obj_t*& t : _map_tiles) t = nullptr;
|
|
lv_obj_t* prev = _scr;
|
|
lv_obj_t* scr = lv_obj_create(NULL);
|
|
s_fit_screen = scr; // rowsFit()
|
|
s_fit_pending = true;
|
|
styleOpaque(scr, theme::BG);
|
|
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
// The title bar, at the very top in place of the status bar: back, the
|
|
// title, the screen's actions (headerButton(), after the title in a row),
|
|
// and at the right end the time and battery. A drag down on it brings the
|
|
// quick panel, as on the status bar.
|
|
int top = theme::STATUS_H;
|
|
_hdr_clock = _hdr_time = _hdr_batt = nullptr;
|
|
if (title) {
|
|
lv_obj_t* hdr = lv_obj_create(scr);
|
|
styleSurface(hdr, theme::BG);
|
|
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
|
lv_obj_set_size(hdr, LV_PCT(100), theme::APPBAR_H);
|
|
lv_obj_set_pos(hdr, 0, 0);
|
|
lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_left(hdr, with_back ? 4 : theme::PAD, 0);
|
|
lv_obj_set_style_pad_column(hdr, theme::GAP, 0);
|
|
lv_obj_add_flag(hdr, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_flag(hdr, LV_OBJ_FLAG_PRESS_LOCK);
|
|
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
|
lv_obj_add_event_cb(hdr, onStatusBarTouch, LV_EVENT_ALL, (void*)1); // a drag only: a tap on the title is nothing
|
|
_header = hdr;
|
|
if (with_back) {
|
|
lv_obj_t* b = lv_button_create(hdr);
|
|
lv_obj_set_size(b, 40, theme::BAR_BTN_H);
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
lv_obj_add_event_cb(b, onBack, LV_EVENT_CLICKED, NULL);
|
|
lv_obj_center(label(b, LV_SYMBOL_LEFT, THEME_FONT_BODY, theme::TEXT));
|
|
}
|
|
lv_obj_t* t = label(hdr, title, THEME_FONT_TITLE, theme::TEXT);
|
|
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
|
|
lv_obj_set_width(t, 1);
|
|
lv_obj_set_flex_grow(t, 1); // what the actions leave
|
|
// The time and battery on a pill as the bar's buttons are, out of the
|
|
// row: the row stops short of it (the bar's right padding, refreshStatusBar()).
|
|
lv_obj_t* c = lv_obj_create(hdr);
|
|
lv_obj_remove_style_all(c);
|
|
lv_obj_add_flag(c, LV_OBJ_FLAG_IGNORE_LAYOUT);
|
|
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_add_flag(c, LV_OBJ_FLAG_PRESS_LOCK);
|
|
lv_obj_remove_flag(c, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
|
lv_obj_add_event_cb(c, onStatusBarTouch, LV_EVENT_ALL, NULL); // a tap opens the panel, as the status bar's
|
|
lv_obj_set_size(c, LV_SIZE_CONTENT, LV_PCT(100));
|
|
lv_obj_set_style_pad_left(c, theme::GAP, 0);
|
|
lv_obj_set_style_pad_right(c, 4, 0);
|
|
lv_obj_set_flex_flow(c, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(c, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_align(c, LV_ALIGN_RIGHT_MID, 0, 0);
|
|
_hdr_clock = c;
|
|
lv_obj_t* p = lv_obj_create(c);
|
|
lv_obj_remove_style_all(p);
|
|
lv_obj_remove_flag(p, LV_OBJ_FLAG_CLICKABLE);
|
|
lv_obj_set_size(p, LV_SIZE_CONTENT, theme::BAR_BTN_H);
|
|
lv_obj_set_flex_flow(p, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(p, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_column(p, 6, 0);
|
|
lv_obj_set_style_bg_color(p, lv_color_hex(theme::SURFACE), 0);
|
|
lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0);
|
|
lv_obj_set_style_radius(p, theme::RADIUS, 0);
|
|
lv_obj_set_style_pad_hor(p, 8, 0);
|
|
_hdr_time = label(p, "", THEME_FONT_BODY, theme::TEXT);
|
|
_hdr_batt = label(p, "", THEME_FONT_ICONS, theme::TEXT_MUTED);
|
|
top = theme::APPBAR_H;
|
|
}
|
|
|
|
lv_obj_t* body = lv_obj_create(scr);
|
|
styleSurface(body, theme::BG);
|
|
lv_obj_set_size(body, lv_display_get_horizontal_resolution(NULL), lv_display_get_vertical_resolution(NULL) - top);
|
|
lv_obj_set_pos(body, 0, top);
|
|
lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_style_pad_all(body, theme::PAD, 0);
|
|
lv_obj_set_style_pad_row(body, theme::GAP, 0);
|
|
_body = body;
|
|
// The new screen emerges from the middle (Anim.h); a screen rebuilt in
|
|
// place (same screen, same title) swaps without motion.
|
|
bool same = _screen == _shown_screen && strncmp(title ? title : "", _shown_title, sizeof(_shown_title) - 1) == 0;
|
|
bool backward = _nav_back || screenDepth(_screen) < screenDepth(_shown_screen);
|
|
_nav_back = false;
|
|
if (!same) prefsSaveSoon(anim::FADE_MS + 60); // left the screen they were changed on
|
|
if (_shown_screen == SCR_HOME && _screen != SCR_HOME) home::leave(); // arranging the apps ends, saved
|
|
_shown_screen = _screen;
|
|
snprintf(_shown_title, sizeof(_shown_title), "%s", title ? title : "");
|
|
_scr = scr;
|
|
if (_fade_next && prev && !_asleep && prev == lv_screen_active()) {
|
|
// A cross-fade: the new screen dissolves in over the old one, deleted after.
|
|
lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_FADE_IN, anim::FADE_MS, 0, true);
|
|
} else {
|
|
lv_screen_load(scr);
|
|
if (prev) lv_obj_delete_async(prev); // this usually runs from one of its own widgets
|
|
if (prev && !same && !_asleep) anim::screenIn(body, backward);
|
|
}
|
|
_fade_next = false;
|
|
statusBarSync();
|
|
refreshStatusBar(); // the title bar's time and battery
|
|
return body;
|
|
}
|
|
|
|
// How deep a screen sits below Home, for the slide direction.
|
|
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_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;
|
|
default: return 3; // pages under a settings page
|
|
}
|
|
}
|
|
|
|
void UITask::back() {
|
|
if (quickPanelOpen()) { quickPanelClose(true); return; }
|
|
_nav_back = true; // the screen shown from here slides back
|
|
switch (_screen) {
|
|
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
|
|
case SCR_CONTACTS: showChats(); break;
|
|
case SCR_CHANNEL_EDIT: showChats(); break;
|
|
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
|
case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
|
case SCR_ADMIN_PICK: showHome(); break;
|
|
case SCR_ROUTE: // back to the node; a trace in flight is dropped
|
|
_core->route.cancelTrace();
|
|
_screen = SCR_NODE;
|
|
buildNode();
|
|
break;
|
|
case SCR_ROUTE_PICK: _screen = SCR_ROUTE; buildRoute(); break;
|
|
case SCR_OTA: otaLeave(); break;
|
|
case SCR_COMPASS: showHome(); 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;
|
|
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 && s_opts_from_map) {
|
|
// The map as it was left -- its mode and view, Map tools over it,
|
|
// scrolled where it was -- dissolving in over the page.
|
|
const bool tools = s_nav_section >= 0;
|
|
_fade_next = true;
|
|
showMap(false);
|
|
if (tools) navToolsPopup(true);
|
|
} else if (_settings_page == settings::PG_RADIO) {
|
|
showRadio();
|
|
} else {
|
|
showSettings();
|
|
}
|
|
break;
|
|
case SCR_QUICK: // back to Messages & contacts' bottom, where the row is
|
|
if (_nav_overlay) { navClosePopup(); break; }
|
|
showSchemaSettings(settings::PG_MESSAGES);
|
|
if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
|
|
break;
|
|
case SCR_MELODY: // back to the Sound page's bottom, where the melodies are
|
|
melodySave();
|
|
stopMelody();
|
|
showSchemaSettings(settings::PG_SOUND);
|
|
if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
|
|
break;
|
|
case SCR_CLOCK: showHome(); break;
|
|
case SCR_RADIO:
|
|
if (radioPopupOpen()) radioCloseFreq();
|
|
else if (_nav_overlay) navClosePopup();
|
|
else showSettings();
|
|
break;
|
|
case SCR_REPEATER:
|
|
if (radioPopupOpen()) radioCloseFreq();
|
|
else if (_nav_overlay) navClosePopup();
|
|
else showHome();
|
|
break;
|
|
case SCR_SCOPES: // back to the Radio screen's bottom, where Scopes is
|
|
if (_nav_overlay) { navClosePopup(); break; }
|
|
showRadio();
|
|
if (_body) { layoutNow(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
|
|
break;
|
|
case SCR_CHATS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
|
case SCR_NEARBY:
|
|
if (_nav_overlay) navClosePopup(); // the advert popup
|
|
else if (_scan_overlay) closeScanPopup();
|
|
else showHome();
|
|
break;
|
|
case SCR_NODE: // back to where the node was picked: map, the list, or the scan popup over it
|
|
if (_node_from_map) { _map_nodes = true; showMap(false); break; } // the nodes map as it was left
|
|
_screen = SCR_NEARBY;
|
|
buildNearby();
|
|
if (_node_from_scan) showScanPopup();
|
|
break;
|
|
case SCR_MAP:
|
|
if (_route_pick) { routeMapDone(); break; }
|
|
if (_nav_overlay) navClosePopup();
|
|
else if (areaSelecting()) areaSelectEnd();
|
|
else if (_dl_overlay) mapDownloadClose();
|
|
else if (_map_nodes || !_map_nav) showNearby(); // opened from Nodes
|
|
else showHome();
|
|
break;
|
|
case SCR_WIFI: showSettings(); break;
|
|
case SCR_STORAGE: storeview::stopWalk(); showSettings(); break;
|
|
default: break;
|
|
}
|
|
_nav_back = false; // only closed a popup
|
|
}
|
|
|
|
// ── Home ──────────────────────────────────────────────────────────────────────
|
|
|
|
static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); }
|
|
static void onOpenSettings(lv_event_t* e) { (void)e; s_ui->showSettings(); }
|
|
|
|
static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
|
|
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 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
|
|
static void onOpenGpsFromSettings(lv_event_t* e);
|
|
static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h
|
|
static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h
|
|
static void onNodeName(lv_event_t* e);
|
|
static void onPowerRow(lv_event_t* e);
|
|
static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
// Home itself (pages, clock, minimap, apps) is HomeScreen.h.
|
|
|
|
#include "VirtualList.h"
|
|
|
|
#include "ChatList.h"
|
|
|
|
#include "NodesView.h"
|
|
|
|
#include "MapScreen.h"
|
|
#include "Chart.h"
|
|
#include "ProfileView.h"
|
|
#include "NavMap.h"
|
|
#include "MapAreas.h"
|
|
#include "MapRegions.h"
|
|
#include "HomeScreen.h"
|
|
#include "ClockScreen.h"
|
|
static void onOpenSchemaPage(lv_event_t* e); // Radio > Advanced
|
|
#include "RadioScreen.h"
|
|
#include "WifiScreen.h"
|
|
#include "ChannelScreen.h"
|
|
#include "AdminScreen.h"
|
|
#include "RouteScreen.h"
|
|
#include "BotScreen.h"
|
|
|
|
#include "ThreadView.h"
|
|
|
|
#include "SettingsPages.h"
|
|
|
|
#include "ConversationScreen.h"
|
|
#include "DeviceScreen.h"
|
|
#include "DiagScreen.h"
|
|
#include "CompassScreen.h"
|
|
#include "Flashlight.h"
|
|
#include "BatteryScreen.h"
|
|
#include "GpsScreen.h"
|
|
#include "RadioExtras.h"
|
|
#include "RepeaterScreen.h"
|
|
#include "SoundScreen.h"
|
|
#include "QuickScreen.h"
|
|
#include "QuickPanel.h"
|
|
#include "OtaScreen.h"
|
|
#include "StorageScreen.h"
|
|
#include "Splash.h"
|
|
|
|
// The screen on, the full CPU clock while it's worth having: touched in the
|
|
// last CPU_IDLE_MS, something moving, map tiles to decode, a download or an
|
|
// update on WiFi. Otherwise 80 MHz (lvport::cpuSlow) -- a clock ticking over,
|
|
// a message being read.
|
|
static constexpr uint32_t CPU_IDLE_MS = 2000;
|
|
bool UITask::cpuNeeded() {
|
|
#ifdef UI_PERF_TEST
|
|
return true; // measured at the full clock
|
|
#endif
|
|
if (lv_display_get_inactive_time(NULL) < CPU_IDLE_MS || lv_anim_count_running() > 0) return true;
|
|
if (wifiInUse() || mapview::s_dl.liveQueued() > 0) return true;
|
|
if (_screen == SCR_MAP && _map_pending) return true;
|
|
if (_screen == SCR_HOME && home::mini::s_area && home::mini::s_pending) return true;
|
|
return false;
|
|
}
|
|
|
|
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
void UITask::simCompose(const char* text) {
|
|
if (!_compose_ta) return;
|
|
setKeyboardVisible(true);
|
|
lv_textarea_set_text(_compose_ta, text);
|
|
}
|
|
|
|
void UITask::simDm() {
|
|
for (int i = 0; i < the_mesh.getNumContacts(); i++) {
|
|
ContactInfo c;
|
|
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) && c.type == ADV_TYPE_CHAT) { openDM(c.id.pub_key); return; }
|
|
}
|
|
}
|
|
|
|
void UITask::simUnread() {
|
|
_core->simUnreadDemo();
|
|
if (_screen == SCR_CHATS) refreshChats();
|
|
}
|
|
|
|
// Sim tests: straight to a screen by name.
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
|
|
if (!s_ui) return;
|
|
struct { const char* n; void (UITask::*fn)(); } const SCREENS[] = {
|
|
{ "home", &UITask::showHome }, { "chats", &UITask::showChats }, { "settings", &UITask::showSettings },
|
|
{ "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 },
|
|
{ "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts },
|
|
};
|
|
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, "gps")) { s_ui->showGps(false); return; }
|
|
if (!strcmp(name, "nodesmap")) { s_ui->openMap(false); return; } // as from Nodes
|
|
if (!strcmp(name, "battcurve")) { s_ui->showBattCurve(); return; }
|
|
if (!strcmp(name, "homeedit")) { s_ui->showHome(); s_ui->homeEdit(true); return; } // arranging the apps
|
|
if (!strncmp(name, "accent@", 7)) { theme::setAccent(atoi(name + 7)); return; } // "accent@4": Cyan
|
|
if (!strncmp(name, "map@", 4)) { s_ui->simMapAt(name + 4); return; } // "map@lat,lon,z"
|
|
if (!strncmp(name, "channel@", 8)) { s_ui->openChannel(atoi(name + 8)); return; }
|
|
if (!strncmp(name, "compose@", 8)) { s_ui->simCompose(name + 8); return; }
|
|
if (!strcmp(name, "dm")) { s_ui->simDm(); return; }
|
|
if (!strcmp(name, "send")) { s_ui->simSend(); return; }
|
|
if (!strcmp(name, "unread")) { s_ui->simUnread(); return; }
|
|
if (!strcmp(name, "sleep")) { s_ui->simSleep(true); return; }
|
|
if (!strcmp(name, "wake")) { s_ui->simSleep(false); return; }
|
|
if (!strcmp(name, "lock")) { s_ui->simLock(true); return; }
|
|
if (!strcmp(name, "unlock")) { s_ui->simLock(false); return; }
|
|
if (!strcmp(name, "vector")) { s_ui->setVectorMap(true); return; }
|
|
if (!strcmp(name, "maptools")) { s_ui->navToolsPopup(); return; }
|
|
if (!strcmp(name, "areasel")) { s_ui->areaSelectBegin(); return; }
|
|
if (!strcmp(name, "areas")) { s_ui->mapAreasPopup(); return; }
|
|
if (!strcmp(name, "regions")) { s_ui->mapRegionsPopup(); return; }
|
|
if (!strcmp(name, "region0")) { s_ui->mapRegionPopup(0); return; }
|
|
if (!strcmp(name, "advert")) { s_ui->advertPopup(); return; }
|
|
if (!strcmp(name, "trailsave")) { s_ui->navToolAction(navmap::TL_TRAIL_SAVE); return; } // Map tools > Save
|
|
if (!strcmp(name, "histdemo")) { // 8 h of made-up history, for the charts
|
|
using namespace diagview;
|
|
for (int i = 0; i < 96; i++) {
|
|
s_hist.batt.push((int16_t)(4150 - i * 4 + (i % 7) * 3));
|
|
s_hist.noise.push((int16_t)(-112 + (i % 11 == 0 ? 6 : 0) + (i / 24)));
|
|
s_hist.traffic.push((int16_t)((i * 37) % 13 + (i % 16 == 0 ? 20 : 0)));
|
|
}
|
|
for (int i = 0; i < 32; i++) s_hist.snr.push((int16_t)(4 * (8 - (i * 7) % 19)));
|
|
s_hist.version++;
|
|
s_near.f0 = 869.525f - s_near.step * (s_near.n / 2); // and a noise sweep
|
|
for (int i = 0; i < s_near.n; i++) s_near.v[i] = (int16_t)(-118 + (i % 9 == 0 ? 7 : (i * 5) % 4));
|
|
s_floor_med = -116; s_floor_lo = -118;
|
|
s_noise_have = true;
|
|
return;
|
|
}
|
|
if (!strcmp(name, "profdemo")) { // a made-up height profile in a popup, for the chart
|
|
using namespace profileview;
|
|
s_prof = trailprofile::Sampler();
|
|
for (int i = 0; i < 100; i++) {
|
|
s_prof.dist[i] = i * 62.0f;
|
|
s_prof.alt[i] = (int16_t)(420 + 80 * sinf(i / 14.0f) + i * 1.5f);
|
|
s_prof.st.add(s_prof.alt[i]);
|
|
}
|
|
s_prof.n = 100; s_prof.total = 99 * 62.0f;
|
|
lv_obj_t* box = lv_obj_create(lv_layer_top());
|
|
styleSurface(box, theme::BG);
|
|
lv_obj_set_size(box, 304, 132);
|
|
lv_obj_center(box);
|
|
profileChart(box, 120);
|
|
return;
|
|
}
|
|
if (!strcmp(name, "savedtrail0")) { navmap::scanTrails(); s_ui->savedTrailPopup(0); return; }
|
|
if (!strncmp(name, "trail@", 6)) { // "trail@name.trl" from /sdcard/trails: Load, as the saved-trail popup does
|
|
navmap::scanTrails();
|
|
for (int i = 0; i < navmap::s_st_n; i++)
|
|
if (!strcmp(navmap::s_st_names[i], name + 6)) { navmap::s_st_sel = i; s_ui->savedTrailAction(navmap::TL_ST_LOAD); }
|
|
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
|
|
|
|
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
// The simulator page opens a screen by name (screenshots of the layout):
|
|
// home, chats, contacts, settings, settings:<page>, nearby, map, navmap, places, clock, radio,
|
|
// gps, diag, compass, storage, repeater, wifi, bot, quick, node, channel, layer:<n>.
|
|
extern "C" EMSCRIPTEN_KEEPALIVE int sim_ui_show(const char* name) {
|
|
if (!s_ui || !name) return 0;
|
|
UITask& u = *s_ui;
|
|
if (!strcmp(name, "home")) u.showHome();
|
|
else if (!strcmp(name, "chats")) u.showChats();
|
|
else if (!strcmp(name, "contacts")) u.showContacts();
|
|
else if (!strcmp(name, "settings")) u.showSettings();
|
|
else if (!strncmp(name, "settings:", 9)) u.showSchemaSettings(atoi(name + 9));
|
|
else if (!strcmp(name, "nearby")) u.showNearby();
|
|
else if (!strcmp(name, "map")) u.openMap(false);
|
|
else if (!strcmp(name, "navmap")) u.openMap(true);
|
|
else if (!strcmp(name, "places")) { u.openMap(true); u.navTargetsPopup(); }
|
|
else if (!strncmp(name, "layer:", 6)) u.setMapLayer(atoi(name + 6)); // the map's base layer (MapLayers.h)
|
|
else if (!strcmp(name, "clock")) u.showClock();
|
|
else if (!strcmp(name, "radio")) u.showRadio();
|
|
else if (!strcmp(name, "gps")) u.showGps(false);
|
|
else if (!strcmp(name, "diag")) u.showDiag();
|
|
else if (!strcmp(name, "compass")) u.showCompass();
|
|
else if (!strcmp(name, "storage")) u.showStorage();
|
|
else if (!strcmp(name, "repeater")) u.showRepeater();
|
|
else if (!strcmp(name, "wifi")) u.showWifi();
|
|
else if (!strcmp(name, "bot")) u.showBot();
|
|
else if (!strcmp(name, "quick")) u.showQuickMsgs();
|
|
else if (!strcmp(name, "node")) { u.showNearby(); u.openNode(0); }
|
|
else if (!strcmp(name, "channel")) u.openChannel(0);
|
|
else return 0;
|
|
return 1;
|
|
}
|
|
#endif
|