From 51530ac4511199f0075b7b868973ee98376a233b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:17:38 +0200 Subject: [PATCH] feat(ui-lvgl): SD card as a USB drive, message banner, card format, restart screen - USB through TinyUSB (ARDUINO_USB_MODE=0) instead of USB-Serial/JTAG: the serial port stays, and plugged into a computer (enumerated, the bus not suspended -- on battery an unplug only shows as a suspend) the device asks "Charge only / USB drive". As a drive the firmware lets go of the card and the computer gets its sectors; ejected or unplugged, the device restarts. - Incoming messages: a banner slides down from under the status bar (icon, sender or channel, two lines of the text from history); a tap opens the conversation (not while locked), a swipe up dismisses it, not shown for the conversation already open. Toasts moved up there too, under the banner when both are up; the ones only echoing a switch or a choice are gone. - Storage > Format card: a popup saying what goes, then Format tapped twice; one FAT32 volume over the whole card (readable or not), then a restart. - A deliberate restart shows a full "Restarting..." screen with the reason instead of the screen just freezing. - The screenshot flash is a thin frame round the edges: a full-screen fade redrew all 320x240 each step and tore. Co-Authored-By: Claude Opus 5.5 --- examples/companion_radio/ui-lvgl/LvglPort.h | 164 ++++++++++++- examples/companion_radio/ui-lvgl/MapScreen.h | 8 +- .../companion_radio/ui-lvgl/RepeaterScreen.h | 1 - .../companion_radio/ui-lvgl/StorageScreen.h | 153 +++++++++++- examples/companion_radio/ui-lvgl/UITask.cpp | 222 +++++++++++++++--- examples/companion_radio/ui-lvgl/UITask.h | 5 + examples/companion_radio/ui-lvgl/WifiScreen.h | 1 - solo/wio-tracker-l2/platformio.ini | 6 + 8 files changed, 512 insertions(+), 48 deletions(-) diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index 1d35867e..b44e53db 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -18,10 +18,19 @@ #include #if ESP_ARDUINO_VERSION_MAJOR >= 3 #include + #include + #include + #endif + #if ESP_ARDUINO_VERSION_MAJOR >= 3 && !ARDUINO_USB_MODE && CONFIG_TINYUSB_MSC_ENABLED + #define LVPORT_USB_DRIVE 1 // the SD card lent to a computer (TinyUSB mass storage) + #include + #include #endif #include #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) #include + #include + #include #endif namespace lvport { @@ -184,16 +193,137 @@ static void setBacklightPct(uint8_t pct) { // P14) is switched on in WioTrackerL2Board::begin(). Retried until it works -- // at most every 5 s, a failed mount takes a while -- so a card inserted later // is picked up. +static bool s_sd_mounted = false; +static uint32_t s_sd_last_try = 0; static bool mountStorage() { - static bool mounted = false; - static uint32_t last_try = 0; - if (mounted) return true; - if (last_try && millis() - last_try < 5000) return false; - last_try = millis() | 1; + if (s_sd_mounted) return true; + if (s_sd_last_try && millis() - s_sd_last_try < 5000) return false; + s_sd_last_try = millis() | 1; SD_MMC.setPins(2, 3, 1); - mounted = SD_MMC.begin("/sdcard", true /* 1-bit */); - return mounted; + s_sd_mounted = SD_MMC.begin("/sdcard", true /* 1-bit */); + return s_sd_mounted; } +// Settings > Storage > Format card: one FAT32 volume over the whole card +// (16 KB clusters -- map tiles are small), readable or not. Blocks for a few +// seconds to tens of seconds (the FATs of a big card). The caller restarts +// afterwards: map, history and tile caches all hold state read off the card. +static bool formatCard() { +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + SD_MMC.end(); + s_sd_mounted = false; + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.flags = SDMMC_HOST_FLAG_1BIT; + sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT(); + slot.width = 1; + slot.clk = GPIO_NUM_2; + slot.cmd = GPIO_NUM_3; + slot.d0 = GPIO_NUM_1; + slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + esp_vfs_fat_mount_config_t mc = {}; + mc.format_if_mount_failed = true; // an unreadable card: formatted right here + mc.max_files = 4; + mc.allocation_unit_size = 16 * 1024; + sdmmc_card_t* card = nullptr; + esp_err_t e = esp_vfs_fat_sdmmc_mount("/sdcard", &host, &slot, &mc, &card); + if (e == ESP_OK) { + e = esp_vfs_fat_sdcard_format("/sdcard", card); + esp_vfs_fat_sdcard_unmount("/sdcard", card); + } + s_sd_last_try = 0; + mountStorage(); + return e == ESP_OK; +#else + return false; // IDF 4.4 can't format a mounted card: a computer can +#endif +} +static void restart() { ESP.restart(); } + +// ── The card as a USB drive ────────────────────────────────────────────────── +// A computer (not a charger: USB enumerated the device) is offered the card; +// taken, the firmware lets go of it (the filesystem unmounted, the card kept +// initialised) and the computer reads and writes its sectors directly. Given +// back -- ejected on the computer, or the cable out -- the device restarts: +// what it keeps from the card (map index, history, tile caches) is reloaded. +#ifdef LVPORT_USB_DRIVE +static USBMSC s_msc; // global: its constructor adds the interface before USB starts +static sdmmc_card_t s_msc_card; +static bool s_usb_drive = false; +static volatile bool s_usb_ejected = false; +static int32_t mscRead(uint32_t lba, uint32_t offset, void* buf, uint32_t n) { + lba += offset / 512; + return sdmmc_read_sectors(&s_msc_card, buf, lba, n / 512) == ESP_OK ? (int32_t)n : -1; +} +static int32_t mscWrite(uint32_t lba, uint32_t offset, uint8_t* buf, uint32_t n) { + lba += offset / 512; + return sdmmc_write_sectors(&s_msc_card, buf, lba, n / 512) == ESP_OK ? (int32_t)n : -1; +} +static bool mscStartStop(uint8_t power, bool start, bool eject) { + (void)power; + if (eject && !start) s_usb_ejected = true; + return true; +} +// A computer on the other end: enumerated and the bus not suspended. On +// battery the chip can't see the cable go (no VBUS sense), but the bus falls +// idle -- a suspend -- within milliseconds; plugged back in, the computer +// resets and enumerates it again. (A computer that sleeps suspends it too.) +static volatile bool s_usb_suspended = false; +static void usbEvent(void*, esp_event_base_t, int32_t id, void*) { + if (id == ARDUINO_USB_SUSPEND_EVENT) s_usb_suspended = true; + else if (id == ARDUINO_USB_RESUME_EVENT || id == ARDUINO_USB_STARTED_EVENT) s_usb_suspended = false; +} +static bool usbHost() { + static bool hooked = false; + if (!hooked) { hooked = true; USB.onEvent(usbEvent); } + return (bool)USB && !s_usb_suspended; +} +static bool usbDriveOn() { return s_usb_drive; } +static bool usbDriveStart() { + if (s_usb_drive) return true; + SD_MMC.end(); + s_sd_mounted = false; + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.flags = SDMMC_HOST_FLAG_1BIT; + sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT(); + slot.width = 1; + slot.clk = GPIO_NUM_2; + slot.cmd = GPIO_NUM_3; + slot.d0 = GPIO_NUM_1; + slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + bool ok = sdmmc_host_init() == ESP_OK; + if (ok && (sdmmc_host_init_slot(host.slot, &slot) != ESP_OK || sdmmc_card_init(&host, &s_msc_card) != ESP_OK)) { + sdmmc_host_deinit(); + ok = false; + } + if (!ok) { s_sd_last_try = 0; mountStorage(); return false; } + s_msc.vendorID("MeshCore"); + s_msc.productID("Wio L2 SD card"); + s_msc.productRevision("1.0"); + s_msc.onRead(mscRead); + s_msc.onWrite(mscWrite); + s_msc.onStartStop(mscStartStop); + s_msc.isWritable(true); + s_msc.begin(s_msc_card.csd.capacity, s_msc_card.csd.sector_size); + s_usb_ejected = false; + s_usb_drive = true; + s_msc.mediaPresent(true); + return true; +} +// Ejected on the computer, or the cable out: time to give the card back. +static bool usbDriveDone() { return s_usb_drive && (s_usb_ejected || !usbHost()); } +static void usbDriveStop() { + if (!s_usb_drive) return; + s_msc.mediaPresent(false); // a transfer under way still finishes + delay(100); + sdmmc_host_deinit(); + s_usb_drive = false; +} +#else +static bool usbHost() { return false; } +static bool usbDriveOn() { return false; } +static bool usbDriveStart() { return false; } +static bool usbDriveDone() { return false; } +static void usbDriveStop() {} +#endif // ── Settings kept in NVS ───────────────────────────────────────────────────── // Not in NodePrefs (whose on-flash layout stays fixed) and not on the @@ -606,6 +736,26 @@ static void setBacklightPct(uint8_t pct) { (void)pct; } // the browser canvas // The host page preloads map tiles into the in-memory FS under /sdcard/maps. static bool mountStorage() { return true; } +static void rmTree(const char* path) { + if (DIR* d = opendir(path)) { + while (struct dirent* de = readdir(d)) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue; + char p[256]; + snprintf(p, sizeof(p), "%s/%s", path, de->d_name); + struct stat st; + if (stat(p, &st) == 0 && S_ISDIR(st.st_mode)) { rmTree(p); rmdir(p); } + else remove(p); + } + closedir(d); + } +} +static bool formatCard() { rmTree("/sdcard"); return true; } // the MEMFS card: emptied +static bool usbHost() { return false; } // no USB in a browser +static bool usbDriveOn() { return false; } +static bool usbDriveStart() { return false; } +static bool usbDriveDone() { return false; } +static void usbDriveStop() {} +static void restart() {} // the page's Reset does that // The browser is always online; saved WiFi networks only live for the session. static void wifiStoreLoad() { s_net_count = 0; } diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 744a21d0..29ec5fe8 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -910,8 +910,8 @@ void UITask::setTrails(bool on) { mapview::s_trails_on = on; mapview::s_cache.invalidate(); if (_screen == SCR_MAP) layoutMap(); - if (!on) showToast("Hiking trails off"); - else if (lvport::liveTiles() && lvport::wifiAllowed()) showToast("Trails load with the map over WiFi", 3000); + if (!on) return; // turned on: where the trails come from + if (lvport::liveTiles() && lvport::wifiAllowed()) showToast("Trails load with the map over WiFi", 3000); else showToast("Download an area again to add its trails", 3000); } @@ -922,7 +922,7 @@ void UITask::setVectorMap(bool on) { mapview::s_cache.invalidate(); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); if (_screen == SCR_MAP) layoutMap(); - showToast(on ? "Vector map on (where /vmap has data)" : "Vector map off"); + if (on) showToast("Drawn where /vmap has data"); } void UITask::setLiveTiles(bool on) { @@ -931,12 +931,10 @@ void UITask::setLiveTiles(bool on) { char ssid[33], pass[65]; if (!lvport::wifiAllowed()) showToast("WiFi is off - Settings > WiFi"); else if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) showToast("Pick a WiFi network first - Settings > WiFi", 3000); - else showToast("Missing tiles load over WiFi"); mapLiveBegin(); mapview::s_available = lvport::mountStorage() && mapview::s_provider->available(); } else { mapview::s_dl.liveEnd(); - showToast("Live tiles off"); } if (_screen == SCR_MAP) layoutMap(); } diff --git a/examples/companion_radio/ui-lvgl/RepeaterScreen.h b/examples/companion_radio/ui-lvgl/RepeaterScreen.h index 1e123612..35fbf298 100644 --- a/examples/companion_radio/ui-lvgl/RepeaterScreen.h +++ b/examples/companion_radio/ui-lvgl/RepeaterScreen.h @@ -112,7 +112,6 @@ void UITask::repeaterSet(int which, int v) { switch (which) { case RP_ON: rptctl::setEnabled(p, v != 0); - showToast(v ? "Repeater on" : "Repeater off"); break; case RP_NETWORK: rptctl::setUseProfile(p, v != 0); break; case RP_PRESET: if (v == 0) return; rptctl::choosePreset(p, v - 1); break; diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index 97dec812..c254a62e 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -42,6 +42,9 @@ static lv_obj_t* s_live_lbl = nullptr; // "Delete live map tiles" row labe static lv_obj_t* s_live_info = nullptr; // count / size from the index static uint32_t s_live_armed_ms = 0; static uint32_t s_shown_ms = 0; +static lv_obj_t* s_fmt_overlay = nullptr; // Format card: the popup +static lv_obj_t* s_fmt_go_lbl = nullptr; // its Format button's label +static bool s_fmt_armed = false; // Format tapped once static void fmtBytes(char* out, size_t n, uint64_t b) { if (b < 1024) snprintf(out, n, "%u B", (unsigned)b); @@ -248,6 +251,8 @@ static void onLiveCap(lv_event_t* e) { s_ui->storageLiveCap((int)choiceSelected((lv_obj_t*)lv_event_get_target(e))); } static void onLiveClear(lv_event_t* e) { (void)e; s_ui->storageClearLive(); } +static void onFormatAsk(lv_event_t* e) { (void)e; s_ui->storageFormatAsk(); } +static void onFormatTap(lv_event_t* e) { s_ui->storageFormatTap(lv_event_get_user_data(e) != nullptr); } void UITask::showStorage() { _screen = SCR_STORAGE; @@ -258,6 +263,8 @@ void UITask::buildStorage() { using namespace storeview; lv_obj_t* body = newScreen("Storage", true); s_sd_bar = s_sd_lbl = s_status = s_clear_lbl = s_live_lbl = s_live_info = nullptr; + s_fmt_overlay = s_fmt_go_lbl = nullptr; // went with the old screen + s_fmt_armed = false; memset(s_cat_val, 0, sizeof(s_cat_val)); s_clear_armed_ms = 0; @@ -272,6 +279,7 @@ void UITask::buildStorage() { s_sd_read = false; // read in pollStorage(): the first read of a big card takes a moment startWalk(); } + actionRow(g, LV_SYMBOL_WARNING " Format card", onFormatAsk, NULL, theme::FAIL); // also one the device can't read g = group(body, "DEVICE"); lv_obj_t* flbl; @@ -354,7 +362,6 @@ void UITask::storageKeep(int idx) { lvport::saveHistKeep(idx); s_archive.setKeep(histstore::KEEP[idx]); s_archive.applyKeep(); // trims (or makes room in) every file now - showToast("Saved"); storeview::startWalk(); } @@ -378,7 +385,6 @@ void UITask::storageLiveCap(int idx) { if (idx < 0 || idx >= mapview::LIVE_CAP_COUNT) return; lvport::saveLiveCap(idx); mapview::s_live_cache.setLimit((uint64_t)mapview::LIVE_CAP_MB[idx] << 20); // trimmed in the background - showToast("Saved"); } void UITask::storageClearLive() { @@ -393,3 +399,146 @@ void UITask::storageClearLive() { if (s_live_lbl) lv_label_set_text(s_live_lbl, LV_SYMBOL_TRASH " Delete live map tiles"); showToast("Deleting live map tiles"); } + +// Format card, confirmed twice: the popup says what goes, then its Format +// button wants a second tap. The card is formatted there and then (the loop +// blocks: a big card takes a while), and the device restarts. +static lv_obj_t* fmtButton(lv_obj_t* row, const char* text, uint32_t bg, bool go) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_height(b, 38); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(bg), 0); + lv_obj_add_event_cb(b, onFormatTap, LV_EVENT_CLICKED, go ? (void*)1 : nullptr); + lv_obj_t* l = label(b, text, THEME_FONT_BODY, theme::TEXT); + lv_obj_center(l); + return l; +} + +void UITask::storageFormatAsk() { + using namespace storeview; + if (s_fmt_overlay) return; + s_fmt_armed = false; + lv_obj_t* panel = popupOpen(screen(), POP_FIT, s_fmt_overlay); + lv_obj_set_style_pad_row(panel, 8, 0); + label(panel, "Format the SD card?", THEME_FONT_TITLE, theme::TEXT); + lv_obj_t* t = label(panel, "Everything on it is erased: map areas, live map tiles, message history, " + "trails and screenshots. The device restarts afterwards.", + THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + lv_obj_t* row = flexBox(panel, LV_FLEX_FLOW_ROW); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_style_pad_column(row, theme::GAP, 0); + fmtButton(row, "Cancel", theme::SURFACE_2, false); + s_fmt_go_lbl = fmtButton(row, "Format", theme::FAIL, true); +} + +void UITask::storageFormatTap(bool go) { + using namespace storeview; + if (!go) { + if (s_fmt_overlay) lv_obj_delete_async(s_fmt_overlay); + s_fmt_overlay = s_fmt_go_lbl = nullptr; + return; + } + if (!s_fmt_armed) { // the second confirmation + s_fmt_armed = true; + if (s_fmt_go_lbl) lv_label_set_text(s_fmt_go_lbl, "Tap again"); + return; + } + if (s_fmt_go_lbl) lv_label_set_text(s_fmt_go_lbl, "Formatting..."); + lv_refr_now(NULL); // shown before the loop blocks + stopWalk(); + if (mapview::s_dl.active()) mapDownloadStop(); + bool ok = lvport::formatCard(); + if (!ok) { + if (s_fmt_overlay) lv_obj_delete_async(s_fmt_overlay); + s_fmt_overlay = s_fmt_go_lbl = nullptr; + showToast("Couldn't format - is a card in?", 3000); + return; + } + if (s_fmt_overlay) { lv_obj_delete(s_fmt_overlay); s_fmt_overlay = s_fmt_go_lbl = nullptr; } + restartScreen("The SD card is formatted."); + showStorage(); // the sim goes on: the emptied card +} + +// ── The card as a USB drive ── +// Plugged into a computer, the device asks: charge only, or lend it the SD +// card. Lent, a panel stays up over every screen until the computer ejects +// the drive (or the cable comes out); then the device restarts to reload what +// it keeps from the card. lvport's usbDrive*() do the USB side. +namespace usbview { +static lv_obj_t* s_overlay = nullptr; +static bool s_host = false; // a computer seen on USB +} + +static void onUsbTap(lv_event_t* e) { s_ui->usbTap(lv_event_get_user_data(e) != nullptr); } + +static void usbPanel(const char* title, const char* text, bool buttons) { + using namespace usbview; + if (s_overlay) lv_obj_delete(s_overlay); + lv_obj_t* panel = popupOpen(lv_layer_top(), POP_FIT, s_overlay); + lv_obj_set_style_pad_row(panel, 8, 0); + lv_obj_t* row = flexBox(panel, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(row, 8, 0); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + label(row, LV_SYMBOL_USB, THEME_FONT_TITLE, theme::ACCENT); + label(row, title, THEME_FONT_TITLE, theme::TEXT); + lv_obj_t* t = label(panel, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + if (!buttons) return; + lv_obj_t* br = flexBox(panel, LV_FLEX_FLOW_ROW); + lv_obj_set_width(br, LV_PCT(100)); + lv_obj_set_style_pad_column(br, theme::GAP, 0); + for (int i = 0; i < 2; i++) { + lv_obj_t* b = lv_button_create(br); + lv_obj_set_height(b, 38); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(i ? theme::ACCENT : theme::SURFACE_2), 0); + lv_obj_add_event_cb(b, onUsbTap, LV_EVENT_CLICKED, i ? (void*)1 : nullptr); + lv_obj_center(label(b, i ? "USB drive" : "Charge only", THEME_FONT_BODY, i ? theme::BG : theme::TEXT)); + } +} + +void UITask::usbPoll() { + using namespace usbview; + static uint32_t next = 0; + if ((int32_t)(millis() - next) < 0) return; + next = millis() + 250; + if (lvport::usbDriveOn()) { + if (!lvport::usbDriveDone()) return; + lvport::usbDriveStop(); + if (s_overlay) { lv_obj_delete(s_overlay); s_overlay = nullptr; } + wake(); + restartScreen("The computer has given the SD card back."); + return; + } + bool host = lvport::usbHost(); + if (host == s_host) return; + s_host = host; + if (host) { // just plugged into a computer + wake(); + usbPanel("Connected to a computer", + "Charge only, or use the SD card as a USB drive? As a drive, the device can't use the card " + "(maps, message history) until you eject it on the computer.", true); + } else if (s_overlay) { // unplugged before choosing + lv_obj_delete(s_overlay); + s_overlay = nullptr; + } +} + +void UITask::usbTap(bool drive) { + using namespace usbview; + if (s_overlay) { lv_obj_delete_async(s_overlay); s_overlay = nullptr; } + if (!drive) return; + storeview::stopWalk(); + if (mapview::s_dl.active()) mapDownloadStop(); + if (!lvport::usbDriveStart()) { showToast("No SD card to share", 3000); return; } + usbPanel("USB drive", + "The computer has the SD card now. Eject it there (or unplug the cable) when you're done - " + "the device then restarts.", false); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 36b46a85..34722b6a 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -915,6 +915,7 @@ void UITask::loop() { _core->loop(); drainCoreEvents(); prefsFlush(); + usbPoll(); #if defined(UI_HEAP_REPORT) && defined(ESP32) // -D UI_HEAP_REPORT: internal / PSRAM heap once, 20 s after boot (the // framework comparison in docs/development/l2-roadmap.md). @@ -1153,6 +1154,105 @@ void UITask::drainCoreEvents() { } } +// ── 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); + lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_bg_color(s_banner, lv_color_hex(theme::SURFACE), LV_STATE_PRESSED); + lv_obj_set_style_border_color(s_banner, lv_color_hex(theme::ACCENT), 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, 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_update_layout(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); @@ -1160,14 +1260,31 @@ void UITask::onMessageArrived(const UiEvent& ev) { } if (ev.kind == UIEventType::channelMessage) _notif_ch_idx = ev.idx; notify(ev.kind); - char buf[48]; - snprintf(buf, sizeof(buf), "Msg: %.20s", ev.text); + // 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_screen_off; if (_asleep && !wake_disabled && !isClientConnected()) wake(); else if (!_asleep) lv_display_trigger_activity(NULL); - showToast(buf, 3000); + 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); + if (!open_here) bannerShow(icon, ev.text, text); // not over the very conversation it's in if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); // new unread counts (not under an open popup) } @@ -1182,6 +1299,25 @@ bool UITask::isViewingDM(const uint8_t* pub_key) { // ── Status bar + toast (top layer, over every screen) ───────────────────────── 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); + lv_obj_set_style_bg_color(_toast, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_border_color(_toast, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_border_width(_toast, 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::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()); styleSurface(bar, theme::BG); lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); @@ -1204,22 +1340,6 @@ void UITask::buildStatusBar() { 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); - _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); - lv_obj_set_style_bg_color(_toast, lv_color_hex(theme::SURFACE_2), 0); - lv_obj_set_style_border_color(_toast, lv_color_hex(theme::ACCENT), 0); - lv_obj_set_style_border_width(_toast, 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_BOTTOM_MID, 0, -12); - lv_obj_t* tl = label(_toast, "", THEME_FONT_BODY, 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); refreshStatusBar(); } @@ -1293,7 +1413,6 @@ void UITask::refreshStatusBar() { void UITask::setGps(bool on) { if (!_core->setGpsEnabled(on)) { showToast("No GPS on this device"); return; } - showToast(on ? "GPS on, waiting for a fix" : "GPS off"); refreshStatusBar(); } @@ -1308,11 +1427,38 @@ bool UITask::ensureGps() { } // One persistent timer, paused between toasts: hides the toast when it fires. -static void toastTimerCb(lv_timer_t* t) { - anim::fadeHide((lv_obj_t*)lv_timer_get_user_data(t)); +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) and written to the card // as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp. @@ -1367,13 +1513,20 @@ void UITask::takeScreenshot() { free(px); if (!ok) { remove(path); showToast("Couldn't save the screenshot"); return; } - // A white flash, like a camera's, then where it went. - lv_obj_t* flash = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(flash); - lv_obj_set_size(flash, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_color(flash, lv_color_white(), 0); - lv_obj_remove_flag(flash, LV_OBJ_FLAG_CLICKABLE); - anim::run(flash, anim::setBgOpa, LV_OPA_70, LV_OPA_TRANSP, 300, anim::coverDone); + // 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); @@ -1383,10 +1536,15 @@ 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); // a fade-out in progress - lv_obj_set_style_opa(_toast, LV_OPA_COVER, 0); + lv_anim_delete(_toast, NULL); // on its way out lv_obj_remove_flag(_toast, LV_OBJ_FLAG_HIDDEN); - if (!shown) anim::rise(_toast, 12); // a replaced text just changes + // Under the message banner while that's up, else right under the status bar. + int32_t y = theme::STATUS_H + 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); + lv_obj_update_layout(_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); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 0f63822e..a1f1b6c4 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -135,6 +135,7 @@ public: void setKeyboardAlphabets(int main_idx, int alt_sel); void openChannel(uint8_t channel_idx); void openDM(const uint8_t* pub_key); + void bannerOpen(); // the message banner tapped: its conversation void back(); void sendFromCompose(); void setKeyboardVisible(bool show); @@ -256,6 +257,10 @@ public: void storageClearHistory(); void storageLiveCap(int idx); void storageClearLive(); + void storageFormatAsk(); // Storage > Format card: the popup (first confirmation) + void storageFormatTap(bool go); // its buttons: Format twice (the second confirmation), or Cancel + void usbPoll(); // a computer plugged in: offer the SD card as a USB drive + void usbTap(bool drive); // that popup's buttons bool locked() const; void pinKey(const char* key); // the lock screen's keypad void pinSetupPopup(); // Settings > Display & power > Screen PIN diff --git a/examples/companion_radio/ui-lvgl/WifiScreen.h b/examples/companion_radio/ui-lvgl/WifiScreen.h index 155294bd..b15b7417 100644 --- a/examples/companion_radio/ui-lvgl/WifiScreen.h +++ b/examples/companion_radio/ui-lvgl/WifiScreen.h @@ -189,7 +189,6 @@ void UITask::wifiSetAllowed(bool on) { if (mapview::s_dl.active()) mapDownloadStop(); if (_wifi_scanning) { _wifi_scanning = false; lvport::netEnd(); } } - showToast(on ? "WiFi on" : "WiFi off", 1500); if (_screen == SCR_SETTINGS) { // the row's subtitle lv_obj_t* body = _body; int32_t y = body ? lv_obj_get_scroll_y(body) : 0; diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 6d8eeaf1..3ce4a9b7 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -43,8 +43,14 @@ lib_deps = [env:Wio_Tracker_L2_companion_solo_lvgl] extends = Wio_Tracker_L2 platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312-1/platform-espressif32.zip +; USB through TinyUSB, not the chip's USB-Serial/JTAG: the serial port stays +; (CDC, reset-to-flash by the 1200 bps touch) and the SD card can be lent to +; a computer as a USB drive (ui-lvgl/LvglPort.h). A firmware that crashes +; early may need BOOT held over reset to flash. +build_unflags = -DARDUINO_USB_MODE=1 build_flags = ${Wio_Tracker_L2.build_flags} + -D ARDUINO_USB_MODE=0 -I examples/companion_radio/ui-lvgl -D DISPLAY_CLASS=WioTrackerL2Display -D UI_ZOOM=1