mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-08-28 05:24:32 +00:00
v1.5.4: Hop-based eviction, first-boot data cleanup, message performance
- Evict highest-hop unsaved nodes first when peer list is full (nearby peers survive) - Add first-boot SD data cleanup screen before name input - Conversation summaries cache for faster message list rendering - Optimize message view refresh with summary-based change detection - Streamline unread tracking through MessageStore instead of LXMFManager
This commit is contained in:
+2
-2
@@ -6,8 +6,8 @@
|
||||
|
||||
#define RATDECK_VERSION_MAJOR 1
|
||||
#define RATDECK_VERSION_MINOR 5
|
||||
#define RATDECK_VERSION_PATCH 3
|
||||
#define RATDECK_VERSION_STRING "1.5.3"
|
||||
#define RATDECK_VERSION_PATCH 4
|
||||
#define RATDECK_VERSION_STRING "1.5.4"
|
||||
|
||||
// --- Feature Flags ---
|
||||
#define HAS_DISPLAY true
|
||||
|
||||
+27
-3
@@ -31,6 +31,7 @@
|
||||
#include "ui/screens/LvHelpOverlay.h"
|
||||
// Map screen removed
|
||||
#include "ui/screens/LvNameInputScreen.h"
|
||||
#include "ui/screens/LvDataCleanScreen.h"
|
||||
#include "storage/FlashStore.h"
|
||||
#include "storage/SDStore.h"
|
||||
#include "storage/MessageStore.h"
|
||||
@@ -100,6 +101,7 @@ LvSettingsScreen lvSettingsScreen;
|
||||
LvHelpOverlay lvHelpOverlay;
|
||||
// LvMapScreen removed
|
||||
LvNameInputScreen lvNameInputScreen;
|
||||
LvDataCleanScreen lvDataCleanScreen;
|
||||
|
||||
// Tab-screen mapping (4 tabs) — LVGL versions
|
||||
LvScreen* lvTabScreens[LvTabBar::TAB_COUNT] = {};
|
||||
@@ -749,6 +751,23 @@ void setup() {
|
||||
if (lvTabScreens[tab]) ui.setLvScreen(lvTabScreens[tab]);
|
||||
});
|
||||
|
||||
// Data clean screen (first boot only — when SD has old data)
|
||||
lvDataCleanScreen.setDoneCallback([](bool wipe) {
|
||||
if (wipe) {
|
||||
Serial.println("[BOOT] User chose to wipe old data");
|
||||
lvDataCleanScreen.showStatus("Clearing old data...");
|
||||
sdStore.wipeRatputer();
|
||||
if (announceManager) announceManager->clearAll();
|
||||
Serial.println("[BOOT] Old data cleared");
|
||||
lvDataCleanScreen.showStatus("Done! Rebooting...");
|
||||
delay(1500);
|
||||
ESP.restart();
|
||||
} else {
|
||||
Serial.println("[BOOT] User chose to keep old data");
|
||||
ui.setLvScreen(&lvNameInputScreen);
|
||||
}
|
||||
});
|
||||
|
||||
// Name input screen (first boot only — when no display name is set)
|
||||
lvNameInputScreen.setDoneCallback([](const String& name) {
|
||||
String finalName = name;
|
||||
@@ -780,9 +799,14 @@ void setup() {
|
||||
});
|
||||
|
||||
if (userConfig.settings().displayName.isEmpty()) {
|
||||
// Show name input screen (boot mode keeps status/tab bars hidden)
|
||||
ui.setLvScreen(&lvNameInputScreen);
|
||||
Serial.println("[BOOT] Showing name input screen");
|
||||
// First boot — check if SD has old data that should be cleaned
|
||||
if (sdStore.isReady() && sdStore.hasExistingData()) {
|
||||
ui.setLvScreen(&lvDataCleanScreen);
|
||||
Serial.println("[BOOT] Old SD data found, showing data clean screen");
|
||||
} else {
|
||||
ui.setLvScreen(&lvNameInputScreen);
|
||||
Serial.println("[BOOT] Showing name input screen");
|
||||
}
|
||||
} else {
|
||||
// Name already set — go straight to home
|
||||
ui.setBootMode(false);
|
||||
|
||||
@@ -155,15 +155,19 @@ void AnnounceManager::received_announce(
|
||||
if ((int)_nodes.size() >= MAX_NODES) {
|
||||
evictStale();
|
||||
if ((int)_nodes.size() >= MAX_NODES) {
|
||||
uint8_t maxHops = 0;
|
||||
unsigned long oldest = ULONG_MAX;
|
||||
int oldestIdx = -1;
|
||||
int evictIdx = -1;
|
||||
for (int i = 0; i < (int)_nodes.size(); i++) {
|
||||
if (!_nodes[i].saved && _nodes[i].lastSeen < oldest) {
|
||||
if (_nodes[i].saved) continue;
|
||||
if (_nodes[i].hops > maxHops ||
|
||||
(_nodes[i].hops == maxHops && _nodes[i].lastSeen < oldest)) {
|
||||
maxHops = _nodes[i].hops;
|
||||
oldest = _nodes[i].lastSeen;
|
||||
oldestIdx = i;
|
||||
evictIdx = i;
|
||||
}
|
||||
}
|
||||
if (oldestIdx >= 0) _nodes.erase(_nodes.begin() + oldestIdx);
|
||||
if (evictIdx >= 0) _nodes.erase(_nodes.begin() + evictIdx);
|
||||
}
|
||||
}
|
||||
if ((int)_nodes.size() >= MAX_NODES) return;
|
||||
@@ -251,6 +255,14 @@ void AnnounceManager::clearTransientNodes() {
|
||||
}
|
||||
}
|
||||
|
||||
void AnnounceManager::clearAll() {
|
||||
_nodes.clear();
|
||||
_nameCache.clear();
|
||||
_contactsDirty = false;
|
||||
_nameCacheDirty = false;
|
||||
Serial.println("[ANNOUNCE] Cleared all nodes and name cache");
|
||||
}
|
||||
|
||||
void AnnounceManager::saveContact(const DiscoveredNode& node) {
|
||||
std::string hexHash = node.hash.toHex();
|
||||
JsonDocument doc;
|
||||
|
||||
@@ -50,6 +50,7 @@ public:
|
||||
void addManualContact(const std::string& hexHash, const std::string& name);
|
||||
void evictStale(unsigned long maxAgeMs = 3600000);
|
||||
void clearTransientNodes();
|
||||
void clearAll();
|
||||
|
||||
private:
|
||||
void saveContact(const DiscoveredNode& node);
|
||||
|
||||
@@ -146,8 +146,6 @@ void LXMFManager::processIncoming(const uint8_t* data, size_t len, const RNS::By
|
||||
Serial.printf("[LXMF] Message from %s (%d bytes) content_len=%d\n",
|
||||
msg.sourceHash.toHex().substr(0, 8).c_str(), (int)len, (int)msg.content.size());
|
||||
if (_store) { _store->saveMessage(msg); }
|
||||
std::string peerHex = msg.sourceHash.toHex();
|
||||
_unread[peerHex]++;
|
||||
if (_onMessage) { _onMessage(msg); }
|
||||
}
|
||||
|
||||
@@ -163,28 +161,17 @@ std::vector<LXMFMessage> LXMFManager::getMessages(const std::string& peerHex) co
|
||||
}
|
||||
|
||||
int LXMFManager::unreadCount(const std::string& peerHex) const {
|
||||
if (!_unreadComputed) { const_cast<LXMFManager*>(this)->computeUnreadFromDisk(); }
|
||||
if (peerHex.empty()) {
|
||||
int total = 0;
|
||||
for (auto& kv : _unread) total += kv.second;
|
||||
return total;
|
||||
}
|
||||
auto it = _unread.find(peerHex);
|
||||
return (it != _unread.end()) ? it->second : 0;
|
||||
if (!_store) return 0;
|
||||
if (peerHex.empty()) return _store->totalUnreadCount();
|
||||
const ConversationSummary* s = _store->getSummary(peerHex);
|
||||
return s ? s->unreadCount : 0;
|
||||
}
|
||||
|
||||
void LXMFManager::computeUnreadFromDisk() {
|
||||
_unreadComputed = true;
|
||||
if (!_store) return;
|
||||
for (auto& conv : _store->conversations()) {
|
||||
auto msgs = _store->loadConversation(conv);
|
||||
int count = 0;
|
||||
for (auto& m : msgs) { if (m.incoming && !m.read) count++; }
|
||||
if (count > 0) _unread[conv] = count;
|
||||
}
|
||||
const ConversationSummary* LXMFManager::getConversationSummary(const std::string& peerHex) const {
|
||||
if (!_store) return nullptr;
|
||||
return _store->getSummary(peerHex);
|
||||
}
|
||||
|
||||
void LXMFManager::markRead(const std::string& peerHex) {
|
||||
_unread[peerHex] = 0;
|
||||
if (_store) { _store->markConversationRead(peerHex); }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public:
|
||||
std::vector<LXMFMessage> getMessages(const std::string& peerHex) const;
|
||||
int unreadCount(const std::string& peerHex = "") const;
|
||||
void markRead(const std::string& peerHex);
|
||||
const ConversationSummary* getConversationSummary(const std::string& peerHex) const;
|
||||
|
||||
private:
|
||||
bool sendDirect(LXMFMessage& msg);
|
||||
@@ -40,10 +41,6 @@ private:
|
||||
StatusCallback _statusCb;
|
||||
std::deque<LXMFMessage> _outQueue;
|
||||
|
||||
void computeUnreadFromDisk();
|
||||
mutable bool _unreadComputed = false;
|
||||
mutable std::map<std::string, int> _unread;
|
||||
|
||||
// Deduplication: recently seen message IDs
|
||||
std::set<std::string> _seenMessageIds;
|
||||
static constexpr int MAX_SEEN_IDS = 100;
|
||||
|
||||
+159
-15
@@ -24,6 +24,7 @@ bool MessageStore::begin(FlashStore* flash, SDStore* sd) {
|
||||
migrateTruncatedDirs();
|
||||
initReceiveCounter();
|
||||
refreshConversations();
|
||||
buildSummaries();
|
||||
Serial.printf("[MSGSTORE] %d conversations found, receive counter=%lu\n",
|
||||
(int)_conversations.size(), (unsigned long)_nextReceiveCounter);
|
||||
return true;
|
||||
@@ -305,6 +306,19 @@ bool MessageStore::saveMessage(const LXMFMessage& msg) {
|
||||
if (sdOk) enforceSDLimit(peerHex);
|
||||
if (flashOk) enforceFlashLimit(peerHex);
|
||||
|
||||
// Update summary cache
|
||||
{
|
||||
auto& s = _summaries[peerHex];
|
||||
s.lastTimestamp = msg.timestamp;
|
||||
s.lastIncoming = msg.incoming;
|
||||
std::string prefix = msg.incoming ? "Them: " : "You: ";
|
||||
std::string content = msg.content;
|
||||
if (content.size() > 15) content = content.substr(0, 15) + "...";
|
||||
s.lastPreview = prefix + content;
|
||||
s.totalCount++;
|
||||
if (msg.incoming && !msg.read) s.unreadCount++;
|
||||
}
|
||||
|
||||
return sdOk || flashOk;
|
||||
}
|
||||
|
||||
@@ -433,35 +447,54 @@ bool MessageStore::deleteConversation(const std::string& peerHex) {
|
||||
_conversations.erase(
|
||||
std::remove(_conversations.begin(), _conversations.end(), peerHex),
|
||||
_conversations.end());
|
||||
_summaries.erase(peerHex);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MessageStore::markConversationRead(const std::string& peerHex) {
|
||||
auto markInDir = [&](auto openFn, auto writeFn, const String& dir) {
|
||||
// Collect only incoming (_i.json) filenames
|
||||
std::vector<String> incomingFiles;
|
||||
File d = openFn(dir.c_str());
|
||||
if (!d || !d.isDirectory()) return;
|
||||
File entry = d.openNextFile();
|
||||
while (entry) {
|
||||
if (!entry.isDirectory() && isJsonFile(entry.name())) {
|
||||
size_t size = entry.size();
|
||||
if (size > 0 && size < 4096) {
|
||||
String json = entry.readString();
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, json)) {
|
||||
bool incoming = doc["incoming"] | false;
|
||||
bool isRead = doc["read"] | false;
|
||||
if (incoming && !isRead) {
|
||||
doc["read"] = true;
|
||||
String updated;
|
||||
serializeJson(doc, updated);
|
||||
String path = dir + "/" + entry.name();
|
||||
writeFn(path.c_str(), updated);
|
||||
}
|
||||
}
|
||||
String name = entry.name();
|
||||
int len = name.length();
|
||||
// Check for _i.json suffix (incoming)
|
||||
if (len >= 7 && name[len - 6] == 'i') {
|
||||
incomingFiles.push_back(name);
|
||||
}
|
||||
}
|
||||
entry = d.openNextFile();
|
||||
}
|
||||
|
||||
// Sort descending (newest first) to stop early at first already-read
|
||||
std::sort(incomingFiles.begin(), incomingFiles.end(),
|
||||
[](const String& a, const String& b) { return a > b; });
|
||||
|
||||
for (const auto& fname : incomingFiles) {
|
||||
String path = dir + "/" + fname;
|
||||
// Read file via the appropriate storage
|
||||
String json;
|
||||
File f = openFn(path.c_str());
|
||||
if (f && !f.isDirectory()) {
|
||||
size_t size = f.size();
|
||||
if (size > 0 && size < 4096) json = f.readString();
|
||||
f.close();
|
||||
}
|
||||
if (json.length() == 0) continue;
|
||||
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, json)) continue;
|
||||
bool isRead = doc["read"] | false;
|
||||
if (isRead) break; // all older must be read too
|
||||
doc["read"] = true;
|
||||
String updated;
|
||||
serializeJson(doc, updated);
|
||||
writeFn(path.c_str(), updated);
|
||||
}
|
||||
};
|
||||
|
||||
if (_sd && _sd->isReady()) {
|
||||
@@ -477,6 +510,117 @@ void MessageStore::markConversationRead(const std::string& peerHex) {
|
||||
[&](const char* p, const String& d) { _flash->writeString(p, d); return true; },
|
||||
dir);
|
||||
}
|
||||
|
||||
_summaries[peerHex].unreadCount = 0;
|
||||
}
|
||||
|
||||
void MessageStore::buildSummaries() {
|
||||
_summaries.clear();
|
||||
|
||||
for (const auto& peerHex : _conversations) {
|
||||
ConversationSummary summary;
|
||||
|
||||
// Collect filenames from the conversation directory
|
||||
std::vector<String> files;
|
||||
auto collectFiles = [&](File& d) {
|
||||
File entry = d.openNextFile();
|
||||
while (entry) {
|
||||
if (!entry.isDirectory() && isJsonFile(entry.name())) {
|
||||
files.push_back(entry.name());
|
||||
}
|
||||
entry = d.openNextFile();
|
||||
}
|
||||
};
|
||||
|
||||
bool loadedFromSD = false;
|
||||
if (_sd && _sd->isReady()) {
|
||||
String sdDir = sdConversationDir(peerHex);
|
||||
File d = _sd->openDir(sdDir.c_str());
|
||||
if (d && d.isDirectory()) {
|
||||
collectFiles(d);
|
||||
loadedFromSD = true;
|
||||
}
|
||||
}
|
||||
if (!loadedFromSD && _flash) {
|
||||
String dir = conversationDir(peerHex);
|
||||
File d = LittleFS.open(dir);
|
||||
if (d && d.isDirectory()) {
|
||||
collectFiles(d);
|
||||
}
|
||||
}
|
||||
|
||||
summary.totalCount = (int)files.size();
|
||||
|
||||
if (files.empty()) {
|
||||
_summaries[peerHex] = summary;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort filenames — highest counter = most recent (last element)
|
||||
std::sort(files.begin(), files.end());
|
||||
|
||||
// Read the last file for preview/timestamp
|
||||
String lastFilename = files.back();
|
||||
String basePath = loadedFromSD ? sdConversationDir(peerHex) : conversationDir(peerHex);
|
||||
String lastPath = basePath + "/" + lastFilename;
|
||||
|
||||
auto readJsonFile = [&](const String& path) -> String {
|
||||
if (loadedFromSD && _sd && _sd->isReady()) {
|
||||
return _sd->readString(path.c_str());
|
||||
} else if (_flash) {
|
||||
return _flash->readString(path.c_str());
|
||||
}
|
||||
return String("");
|
||||
};
|
||||
|
||||
String json = readJsonFile(lastPath);
|
||||
if (json.length() > 0) {
|
||||
JsonDocument doc;
|
||||
if (!deserializeJson(doc, json)) {
|
||||
summary.lastTimestamp = doc["ts"] | 0.0;
|
||||
std::string content = doc["content"] | "";
|
||||
summary.lastIncoming = doc["incoming"] | false;
|
||||
std::string prefix = summary.lastIncoming ? "Them: " : "You: ";
|
||||
if (content.size() > 15) content = content.substr(0, 15) + "...";
|
||||
summary.lastPreview = prefix + content;
|
||||
}
|
||||
}
|
||||
|
||||
// Count unreads: scan _i.json files (incoming), newest first, stop at first already-read
|
||||
int unread = 0;
|
||||
for (int i = (int)files.size() - 1; i >= 0; i--) {
|
||||
const String& fname = files[i];
|
||||
// Only check incoming files (suffix _i.json)
|
||||
int flen = fname.length();
|
||||
if (flen < 7 || fname[flen - 6] != 'i') continue; // not _i.json
|
||||
|
||||
String fpath = basePath + "/" + fname;
|
||||
String fjson = readJsonFile(fpath);
|
||||
if (fjson.length() > 0) {
|
||||
JsonDocument fdoc;
|
||||
if (!deserializeJson(fdoc, fjson)) {
|
||||
bool isRead = fdoc["read"] | false;
|
||||
if (isRead) break; // all older messages must be read too
|
||||
unread++;
|
||||
}
|
||||
}
|
||||
}
|
||||
summary.unreadCount = unread;
|
||||
_summaries[peerHex] = summary;
|
||||
}
|
||||
|
||||
Serial.printf("[MSGSTORE] Built summaries for %d conversations\n", (int)_summaries.size());
|
||||
}
|
||||
|
||||
const ConversationSummary* MessageStore::getSummary(const std::string& peerHex) const {
|
||||
auto it = _summaries.find(peerHex);
|
||||
return (it != _summaries.end()) ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
int MessageStore::totalUnreadCount() const {
|
||||
int total = 0;
|
||||
for (const auto& kv : _summaries) total += kv.second.unreadCount;
|
||||
return total;
|
||||
}
|
||||
|
||||
String MessageStore::conversationDir(const std::string& peerHex) const {
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
#include <string>
|
||||
#include <map>
|
||||
|
||||
struct ConversationSummary {
|
||||
double lastTimestamp = 0;
|
||||
std::string lastPreview; // first ~20 chars of last message
|
||||
bool lastIncoming = false;
|
||||
int unreadCount = 0;
|
||||
int totalCount = 0;
|
||||
};
|
||||
|
||||
class MessageStore {
|
||||
public:
|
||||
bool begin(FlashStore* flash, SDStore* sd = nullptr);
|
||||
@@ -20,6 +28,9 @@ public:
|
||||
bool deleteConversation(const std::string& peerHex);
|
||||
void markConversationRead(const std::string& peerHex);
|
||||
|
||||
const ConversationSummary* getSummary(const std::string& peerHex) const;
|
||||
int totalUnreadCount() const;
|
||||
|
||||
private:
|
||||
String conversationDir(const std::string& peerHex) const;
|
||||
String sdConversationDir(const std::string& peerHex) const;
|
||||
@@ -28,9 +39,11 @@ private:
|
||||
void migrateFlashToSD();
|
||||
void migrateTruncatedDirs();
|
||||
void initReceiveCounter();
|
||||
void buildSummaries();
|
||||
|
||||
FlashStore* _flash = nullptr;
|
||||
SDStore* _sd = nullptr;
|
||||
std::vector<std::string> _conversations;
|
||||
std::map<std::string, ConversationSummary> _summaries;
|
||||
uint32_t _nextReceiveCounter = 0;
|
||||
};
|
||||
|
||||
@@ -188,6 +188,21 @@ void SDStore::wipeDir(const char* path) {
|
||||
dir.close();
|
||||
}
|
||||
|
||||
bool SDStore::hasExistingData() {
|
||||
if (!_ready) return false;
|
||||
if (SD.exists("/ratputer/config.json")) return true;
|
||||
if (SD.exists("/ratputer/identity/identity")) return true;
|
||||
File dir = SD.open("/ratputer/messages");
|
||||
if (dir && dir.isDirectory()) {
|
||||
File entry = dir.openNextFile();
|
||||
bool found = (bool)entry;
|
||||
if (entry) entry.close();
|
||||
dir.close();
|
||||
if (found) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SDStore::formatForRatputer() {
|
||||
if (!_ready) return false;
|
||||
Serial.println("[SD] Creating Ratputer directory structure...");
|
||||
|
||||
@@ -27,6 +27,7 @@ public:
|
||||
|
||||
bool formatForRatputer();
|
||||
bool wipeRatputer();
|
||||
bool hasExistingData();
|
||||
|
||||
private:
|
||||
void wipeDir(const char* path);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "LvDataCleanScreen.h"
|
||||
#include "ui/Theme.h"
|
||||
#include "ui/LvTheme.h"
|
||||
#include "config/Config.h"
|
||||
|
||||
void LvDataCleanScreen::createUI(lv_obj_t* parent) {
|
||||
_screen = parent;
|
||||
lv_obj_clear_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_hex(Theme::BG), 0);
|
||||
|
||||
// Title: "RATSPEAK"
|
||||
lv_obj_t* title = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(Theme::PRIMARY), 0);
|
||||
lv_label_set_text(title, "RATSPEAK");
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
// Subtitle
|
||||
lv_obj_t* sub = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0);
|
||||
lv_obj_set_style_text_color(sub, lv_color_hex(Theme::ACCENT), 0);
|
||||
lv_label_set_text(sub, "ratspeak.org");
|
||||
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, 42);
|
||||
|
||||
// Message
|
||||
lv_obj_t* msg = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(msg, &lv_font_montserrat_12, 0);
|
||||
lv_obj_set_style_text_color(msg, lv_color_hex(Theme::SECONDARY), 0);
|
||||
lv_label_set_text(msg, "Old data found on SD card.");
|
||||
lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 75);
|
||||
|
||||
// Prompt
|
||||
lv_obj_t* prompt = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0);
|
||||
lv_obj_set_style_text_color(prompt, lv_color_hex(Theme::SECONDARY), 0);
|
||||
lv_label_set_text(prompt, "Remove old data and start fresh?");
|
||||
lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 95);
|
||||
|
||||
// Yes label
|
||||
_yesLabel = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(_yesLabel, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_text(_yesLabel, "[ Yes ]");
|
||||
lv_obj_align(_yesLabel, LV_ALIGN_TOP_MID, -50, 135);
|
||||
|
||||
// No label
|
||||
_noLabel = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(_noLabel, &lv_font_montserrat_14, 0);
|
||||
lv_label_set_text(_noLabel, "[ No ]");
|
||||
lv_obj_align(_noLabel, LV_ALIGN_TOP_MID, 50, 135);
|
||||
|
||||
updateSelection();
|
||||
|
||||
// Hint
|
||||
_hintLabel = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(_hintLabel, &lv_font_montserrat_12, 0);
|
||||
lv_obj_set_style_text_color(_hintLabel, lv_color_hex(Theme::ACCENT), 0);
|
||||
lv_label_set_text(_hintLabel, "[</>] Select [Enter] OK");
|
||||
lv_obj_align(_hintLabel, LV_ALIGN_TOP_MID, 0, 165);
|
||||
|
||||
// Status label (hidden until showStatus is called)
|
||||
_statusLabel = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(_statusLabel, &lv_font_montserrat_12, 0);
|
||||
lv_obj_set_style_text_color(_statusLabel, lv_color_hex(Theme::PRIMARY), 0);
|
||||
lv_label_set_text(_statusLabel, "");
|
||||
lv_obj_align(_statusLabel, LV_ALIGN_TOP_MID, 0, 140);
|
||||
lv_obj_add_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
// Version
|
||||
lv_obj_t* ver = lv_label_create(parent);
|
||||
lv_obj_set_style_text_font(ver, &lv_font_montserrat_10, 0);
|
||||
lv_obj_set_style_text_color(ver, lv_color_hex(Theme::MUTED), 0);
|
||||
char verBuf[32];
|
||||
snprintf(verBuf, sizeof(verBuf), "v%s", RATDECK_VERSION_STRING);
|
||||
lv_label_set_text(ver, verBuf);
|
||||
lv_obj_align(ver, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
}
|
||||
|
||||
void LvDataCleanScreen::updateSelection() {
|
||||
if (_selectedYes) {
|
||||
lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::ACCENT), 0);
|
||||
lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::MUTED), 0);
|
||||
} else {
|
||||
lv_obj_set_style_text_color(_yesLabel, lv_color_hex(Theme::MUTED), 0);
|
||||
lv_obj_set_style_text_color(_noLabel, lv_color_hex(Theme::ACCENT), 0);
|
||||
}
|
||||
}
|
||||
|
||||
void LvDataCleanScreen::showStatus(const char* msg) {
|
||||
if (_yesLabel) lv_obj_add_flag(_yesLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
if (_noLabel) lv_obj_add_flag(_noLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
if (_hintLabel) lv_obj_add_flag(_hintLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
if (_statusLabel) {
|
||||
lv_label_set_text(_statusLabel, msg);
|
||||
lv_obj_clear_flag(_statusLabel, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
lv_timer_handler();
|
||||
}
|
||||
|
||||
bool LvDataCleanScreen::handleKey(const KeyEvent& event) {
|
||||
if (event.left) {
|
||||
_selectedYes = true;
|
||||
updateSelection();
|
||||
return true;
|
||||
}
|
||||
if (event.right) {
|
||||
_selectedYes = false;
|
||||
updateSelection();
|
||||
return true;
|
||||
}
|
||||
if (event.enter || event.character == '\n' || event.character == '\r') {
|
||||
if (_doneCb) _doneCb(_selectedYes);
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all keys
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/UIManager.h"
|
||||
#include <functional>
|
||||
|
||||
class LvDataCleanScreen : public LvScreen {
|
||||
public:
|
||||
void createUI(lv_obj_t* parent) override;
|
||||
bool handleKey(const KeyEvent& event) override;
|
||||
const char* title() const override { return "Setup"; }
|
||||
|
||||
// Callback: true = user chose Yes (wipe), false = user chose No (skip)
|
||||
void setDoneCallback(std::function<void(bool)> cb) { _doneCb = cb; }
|
||||
|
||||
// Replace buttons with a status message (for showing progress)
|
||||
void showStatus(const char* msg);
|
||||
|
||||
private:
|
||||
lv_obj_t* _yesLabel = nullptr;
|
||||
lv_obj_t* _noLabel = nullptr;
|
||||
lv_obj_t* _hintLabel = nullptr;
|
||||
lv_obj_t* _statusLabel = nullptr;
|
||||
bool _selectedYes = true;
|
||||
std::function<void(bool)> _doneCb;
|
||||
void updateSelection();
|
||||
};
|
||||
@@ -114,11 +114,12 @@ void LvMessageView::onEnter() {
|
||||
// Register status callback for live updates
|
||||
std::string peer = _peerHex;
|
||||
_lxmf->setStatusCallback([this, peer](const std::string& peerHex, double, LXMFStatus) {
|
||||
if (peerHex == peer) _lastRefreshMs = 0; // Force rebuild on next refreshUI
|
||||
if (peerHex == peer) _statusDirty = true;
|
||||
});
|
||||
}
|
||||
_lastMsgCount = -1;
|
||||
_lastRefreshMs = 0;
|
||||
_statusDirty = false;
|
||||
_inputText.clear();
|
||||
|
||||
if (_lblHeader) {
|
||||
@@ -129,6 +130,7 @@ void LvMessageView::onEnter() {
|
||||
if (_textarea) {
|
||||
lv_textarea_set_text(_textarea, "");
|
||||
}
|
||||
_cachedMsgs.clear(); // Force fresh load
|
||||
rebuildMessages();
|
||||
}
|
||||
|
||||
@@ -141,28 +143,38 @@ void LvMessageView::onExit() {
|
||||
void LvMessageView::refreshUI() {
|
||||
if (!_lxmf) return;
|
||||
unsigned long now = millis();
|
||||
if (now - _lastRefreshMs >= REFRESH_INTERVAL_MS) {
|
||||
auto newMsgs = _lxmf->getMessages(_peerHex);
|
||||
_lastRefreshMs = now;
|
||||
// Rebuild if count changed or any status changed
|
||||
bool changed = (newMsgs.size() != _cachedMsgs.size());
|
||||
if (!changed) {
|
||||
for (size_t i = 0; i < newMsgs.size(); i++) {
|
||||
if (newMsgs[i].status != _cachedMsgs[i].status) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_cachedMsgs = newMsgs;
|
||||
_lastMsgCount = (int)_cachedMsgs.size();
|
||||
rebuildMessages();
|
||||
if (now - _lastRefreshMs < REFRESH_INTERVAL_MS) return;
|
||||
_lastRefreshMs = now;
|
||||
|
||||
// Quick check: if summary count unchanged and no status callback fired, skip disk load
|
||||
auto* summary = _lxmf->getConversationSummary(_peerHex);
|
||||
if (summary && !_statusDirty && summary->totalCount == (int)_cachedMsgs.size()) return;
|
||||
|
||||
_statusDirty = false;
|
||||
auto newMsgs = _lxmf->getMessages(_peerHex);
|
||||
|
||||
// Rebuild if count changed or any status changed
|
||||
bool changed = (newMsgs.size() != _cachedMsgs.size());
|
||||
if (!changed) {
|
||||
for (size_t i = 0; i < newMsgs.size(); i++) {
|
||||
if (newMsgs[i].status != _cachedMsgs[i].status) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_cachedMsgs = std::move(newMsgs);
|
||||
_lastMsgCount = (int)_cachedMsgs.size();
|
||||
rebuildMessages();
|
||||
}
|
||||
}
|
||||
|
||||
void LvMessageView::rebuildMessages() {
|
||||
if (!_lxmf || !_msgScroll) return;
|
||||
|
||||
_cachedMsgs = _lxmf->getMessages(_peerHex);
|
||||
// Only load from disk if _cachedMsgs is empty (first call or after send)
|
||||
if (_cachedMsgs.empty()) {
|
||||
_cachedMsgs = _lxmf->getMessages(_peerHex);
|
||||
}
|
||||
_lastMsgCount = (int)_cachedMsgs.size();
|
||||
_lastRefreshMs = millis();
|
||||
lv_obj_clean(_msgScroll);
|
||||
|
||||
@@ -269,6 +281,7 @@ void LvMessageView::sendCurrentMessage() {
|
||||
|
||||
_inputText.clear();
|
||||
if (_textarea) lv_textarea_set_text(_textarea, "");
|
||||
_cachedMsgs.clear(); // Force fresh load in rebuildMessages
|
||||
rebuildMessages();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ private:
|
||||
int _lastMsgCount = -1;
|
||||
unsigned long _lastRefreshMs = 0;
|
||||
std::vector<LXMFMessage> _cachedMsgs;
|
||||
bool _statusDirty = false;
|
||||
|
||||
// LVGL widgets
|
||||
lv_obj_t* _header = nullptr;
|
||||
|
||||
@@ -96,15 +96,11 @@ void LvMessagesScreen::rebuildList() {
|
||||
for (int i = 0; i < count; i++) {
|
||||
ConvInfo ci;
|
||||
ci.peerHex = convs[i];
|
||||
ci.hasUnread = _lxmf->unreadCount(ci.peerHex) > 0;
|
||||
auto msgs = _lxmf->getMessages(ci.peerHex);
|
||||
if (!msgs.empty()) {
|
||||
const auto& last = msgs.back();
|
||||
ci.lastTs = last.timestamp;
|
||||
std::string prefix = last.incoming ? "Them: " : "You: ";
|
||||
std::string content = last.content;
|
||||
if (content.size() > 15) content = content.substr(0, 15) + "...";
|
||||
ci.preview = prefix + content;
|
||||
auto* s = _lxmf->getConversationSummary(ci.peerHex);
|
||||
if (s) {
|
||||
ci.lastTs = s->lastTimestamp;
|
||||
ci.preview = s->lastPreview;
|
||||
ci.hasUnread = s->unreadCount > 0;
|
||||
}
|
||||
sorted.push_back(ci);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user