Phase 1 - basic architecture
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
#include "CrossPointState.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
@@ -589,3 +590,65 @@ bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* ne
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- ReadingStatsStore ----
|
||||
|
||||
bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
doc["totalSeconds"] = store.getGlobalTotalSeconds();
|
||||
doc["totalSessions"] = store.getGlobalTotalSessions();
|
||||
doc["totalPagesTurned"] = store.getGlobalTotalPagesTurned();
|
||||
|
||||
JsonArray arr = doc["books"].to<JsonArray>();
|
||||
for (const auto& book : store.getBooks()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["docId"] = book.docId;
|
||||
obj["title"] = book.title;
|
||||
obj["author"] = book.author;
|
||||
obj["totalSeconds"] = book.totalSeconds;
|
||||
obj["pagesTurned"] = book.pagesTurned;
|
||||
obj["sessions"] = book.sessions;
|
||||
obj["firstReadEpoch"] = static_cast<int64_t>(book.firstReadEpoch);
|
||||
obj["lastReadEpoch"] = static_cast<int64_t>(book.lastReadEpoch);
|
||||
obj["progress"] = book.progress;
|
||||
obj["finished"] = book.finished;
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadReadingStats(ReadingStatsStore& store, const char* json) {
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("RST", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.books.clear();
|
||||
store.globalTotalSeconds = doc["totalSeconds"] | (uint32_t)0;
|
||||
store.globalTotalSessions = doc["totalSessions"] | (uint32_t)0;
|
||||
store.globalTotalPagesTurned = doc["totalPagesTurned"] | (uint32_t)0;
|
||||
|
||||
JsonArray arr = doc["books"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
BookReadingStats book;
|
||||
book.docId = obj["docId"] | std::string("");
|
||||
if (book.docId.empty()) continue; // skip corrupt entries
|
||||
book.title = obj["title"] | std::string("");
|
||||
book.author = obj["author"] | std::string("");
|
||||
book.totalSeconds = obj["totalSeconds"] | (uint32_t)0;
|
||||
book.pagesTurned = obj["pagesTurned"] | (uint32_t)0;
|
||||
book.sessions = obj["sessions"] | (uint32_t)0;
|
||||
book.firstReadEpoch = static_cast<time_t>(obj["firstReadEpoch"] | (int64_t)0);
|
||||
book.lastReadEpoch = static_cast<time_t>(obj["lastReadEpoch"] | (int64_t)0);
|
||||
book.progress = obj["progress"] | (uint8_t)0;
|
||||
book.finished = obj["finished"] | false;
|
||||
store.books.push_back(std::move(book));
|
||||
}
|
||||
|
||||
LOG_DBG("RST", "Reading stats loaded (%zu books, %u s total)", store.books.size(), store.globalTotalSeconds);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class WifiCredentialStore;
|
||||
class KOReaderCredentialStore;
|
||||
class RecentBooksStore;
|
||||
class OpdsServerStore;
|
||||
class ReadingStatsStore;
|
||||
|
||||
namespace JsonSettingsIO {
|
||||
|
||||
@@ -33,4 +34,8 @@ bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
||||
bool saveOpds(const OpdsServerStore& store, const char* path);
|
||||
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
// ReadingStatsStore
|
||||
bool saveReadingStats(const ReadingStatsStore& store, const char* path);
|
||||
bool loadReadingStats(ReadingStatsStore& store, const char* json);
|
||||
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "ReadingSessionTracker.h"
|
||||
|
||||
#include <Arduino.h> // millis()
|
||||
#include <HalClock.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include "ReadingStats.h"
|
||||
|
||||
ReadingSessionTracker& globalReadingSessionTracker() {
|
||||
static ReadingSessionTracker instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void ReadingSessionTracker::flushIdleSinceLastActivity() {
|
||||
if (!active) return;
|
||||
const uint32_t now = millis();
|
||||
const uint32_t delta = now - lastActivityMs; // unsigned wrap is fine
|
||||
// Cap idle gaps. A user idle for an hour shouldn't get credited for it.
|
||||
const uint32_t capped = delta > MAX_IDLE_MS ? MAX_IDLE_MS : delta;
|
||||
accumulatedMs += capped;
|
||||
lastActivityMs = now;
|
||||
}
|
||||
|
||||
void ReadingSessionTracker::begin(const std::string& docId_, const std::string& title_, const std::string& author_) {
|
||||
if (active) {
|
||||
// Don't lose data from a previous session that wasn't explicitly closed.
|
||||
end();
|
||||
}
|
||||
active = true;
|
||||
docId = docId_;
|
||||
title = title_;
|
||||
author = author_;
|
||||
walltimeStartEpoch = HalClock::isSynced() ? static_cast<int64_t>(HalClock::now()) : 0;
|
||||
lastActivityMs = millis();
|
||||
accumulatedMs = 0;
|
||||
pagesTurnedThisSession = 0;
|
||||
lastKnownProgress = 0;
|
||||
LOG_DBG("RST", "Session begin doc=%s sync=%d", docId.c_str(), HalClock::isSynced() ? 1 : 0);
|
||||
}
|
||||
|
||||
void ReadingSessionTracker::onPageTurn() {
|
||||
if (!active) return;
|
||||
flushIdleSinceLastActivity();
|
||||
pagesTurnedThisSession += 1;
|
||||
}
|
||||
|
||||
void ReadingSessionTracker::updateProgress(uint8_t progress) {
|
||||
if (!active) return;
|
||||
lastKnownProgress = progress;
|
||||
}
|
||||
|
||||
void ReadingSessionTracker::end() {
|
||||
if (!active) return;
|
||||
// Final idle flush so we credit the time between the last page turn and now,
|
||||
// capped at MAX_IDLE_MS just like all the other gaps.
|
||||
flushIdleSinceLastActivity();
|
||||
|
||||
const uint32_t seconds = static_cast<uint32_t>(accumulatedMs / 1000);
|
||||
// Prefer the walltime captured at begin(); if HalClock has only just become
|
||||
// synced, fall back to "now" as the lastReadEpoch.
|
||||
int64_t walltime = walltimeStartEpoch;
|
||||
if (walltime == 0 && HalClock::isSynced()) {
|
||||
walltime = static_cast<int64_t>(HalClock::now());
|
||||
}
|
||||
|
||||
LOG_DBG("RST", "Session end doc=%s secs=%u pages=%u prog=%u wall=%lld", docId.c_str(), seconds,
|
||||
pagesTurnedThisSession, lastKnownProgress, (long long)walltime);
|
||||
|
||||
if (seconds > 0 && !docId.empty()) {
|
||||
READING_STATS.recordSession(docId, title, author, seconds, pagesTurnedThisSession, lastKnownProgress,
|
||||
static_cast<time_t>(walltime));
|
||||
READING_STATS.saveToFile();
|
||||
}
|
||||
|
||||
active = false;
|
||||
docId.clear();
|
||||
title.clear();
|
||||
author.clear();
|
||||
walltimeStartEpoch = 0;
|
||||
lastActivityMs = 0;
|
||||
accumulatedMs = 0;
|
||||
pagesTurnedThisSession = 0;
|
||||
lastKnownProgress = 0;
|
||||
}
|
||||
|
||||
uint32_t ReadingSessionTracker::getLiveSeconds() const {
|
||||
if (!active) return 0;
|
||||
// Best-effort live readout — does not mutate state, so it does not include
|
||||
// the in-flight idle gap. Good enough for "you've been reading for X".
|
||||
return static_cast<uint32_t>(accumulatedMs / 1000);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// Tracks a single reading session and flushes its accumulated time/pages into
|
||||
// the ReadingStatsStore when it ends.
|
||||
//
|
||||
// Design notes
|
||||
// - Idle-clamped (CrossPet/KOReader pattern). Each page-turn delta is capped
|
||||
// at MAX_IDLE_MS, so leaving a book open overnight doesn't poison totals.
|
||||
// - Two clock sources, mandatory dual-track:
|
||||
// * elapsedMs accumulator driven by millis() — always correct, survives
|
||||
// a never-synced HalClock.
|
||||
// * walltimeStartEpoch snapshot from HalClock::now() if synced — gives
|
||||
// "first read" / "last read" wallclock for the UI.
|
||||
// Whichever is available at flush time wins. If HalClock becomes synced
|
||||
// mid-session, the next flush starts producing real epochs without losing
|
||||
// the elapsed millis already accumulated.
|
||||
// - Lifecycle is owned by the reader activity:
|
||||
// begin(...) on reader enter
|
||||
// onPageTurn() on each forward/backward page turn
|
||||
// end() on reader exit / sleep / power off
|
||||
// Multiple begin() calls without an end() are tolerated — they restart
|
||||
// the session, flushing the prior one first.
|
||||
class ReadingSessionTracker {
|
||||
public:
|
||||
// Maximum gap (millis) between two activity events that still counts toward
|
||||
// session time. Beyond this, the gap is treated as idle and dropped.
|
||||
static constexpr uint32_t MAX_IDLE_MS = 90 * 1000;
|
||||
|
||||
// Start a session for `docId`. If a previous session is still open it is
|
||||
// flushed first under its own docId.
|
||||
void begin(const std::string& docId, const std::string& title, const std::string& author);
|
||||
|
||||
// Notify of any activity that should accumulate reading time. Typically
|
||||
// called from the reader's page-turn path. Safe to call before begin()
|
||||
// (no-op).
|
||||
void onPageTurn();
|
||||
|
||||
// Update the snapshot of the book's progress (0-100). Cached and written
|
||||
// out when the session is flushed. Cheap; OK to call on every page turn.
|
||||
void updateProgress(uint8_t progress);
|
||||
|
||||
// Flush the session into ReadingStatsStore and reset internal state.
|
||||
// Subsequent onPageTurn() calls are no-ops until begin() is called again.
|
||||
// Persists the stats file. If the session contributed no reading time it
|
||||
// is silently dropped (no entry created).
|
||||
void end();
|
||||
|
||||
// True while a session is active (between begin() and end()).
|
||||
bool isActive() const { return active; }
|
||||
|
||||
// Live read-only view of the in-flight session. Useful for UI ("you've
|
||||
// been reading for X minutes"). Returns 0 when no session is active.
|
||||
uint32_t getLiveSeconds() const;
|
||||
uint32_t getLivePages() const { return pagesTurnedThisSession; }
|
||||
const std::string& getDocId() const { return docId; }
|
||||
|
||||
private:
|
||||
void flushIdleSinceLastActivity();
|
||||
|
||||
bool active = false;
|
||||
std::string docId;
|
||||
std::string title;
|
||||
std::string author;
|
||||
|
||||
// Wall clock anchor at begin() — 0 if HalClock wasn't synced then.
|
||||
// We don't refresh this if HalClock becomes synced mid-session; we just
|
||||
// pick it up on the next session.
|
||||
int64_t walltimeStartEpoch = 0;
|
||||
|
||||
// millis() at the most recent activity (begin or page turn). Used to
|
||||
// compute the next idle-clamped delta.
|
||||
uint32_t lastActivityMs = 0;
|
||||
|
||||
// Idle-clamped accumulator (milliseconds).
|
||||
uint64_t accumulatedMs = 0;
|
||||
|
||||
uint32_t pagesTurnedThisSession = 0;
|
||||
uint8_t lastKnownProgress = 0;
|
||||
};
|
||||
|
||||
// Global tracker singleton; readers route their lifecycle calls through here.
|
||||
ReadingSessionTracker& globalReadingSessionTracker();
|
||||
@@ -0,0 +1,73 @@
|
||||
#include "ReadingStats.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
constexpr char READING_STATS_FILE[] = "/.crosspoint/reading-stats.json";
|
||||
} // namespace
|
||||
|
||||
ReadingStatsStore ReadingStatsStore::instance;
|
||||
|
||||
void ReadingStatsStore::recordSession(const std::string& docId, const std::string& title, const std::string& author,
|
||||
uint32_t sessionSeconds, uint32_t sessionPagesTurned, uint8_t progress,
|
||||
time_t walltimeEpoch) {
|
||||
if (docId.empty() || sessionSeconds == 0) {
|
||||
// Nothing to credit. Title-update-only flows go through a different path.
|
||||
return;
|
||||
}
|
||||
|
||||
auto it = std::find_if(books.begin(), books.end(),
|
||||
[&docId](const BookReadingStats& b) { return b.docId == docId; });
|
||||
if (it == books.end()) {
|
||||
BookReadingStats fresh;
|
||||
fresh.docId = docId;
|
||||
fresh.title = title;
|
||||
fresh.author = author;
|
||||
books.push_back(std::move(fresh));
|
||||
it = books.end() - 1;
|
||||
} else {
|
||||
// Update title/author opportunistically — they may have been blank if the
|
||||
// book was first opened before metadata was indexed.
|
||||
if (!title.empty()) it->title = title;
|
||||
if (!author.empty()) it->author = author;
|
||||
}
|
||||
|
||||
it->totalSeconds += sessionSeconds;
|
||||
it->pagesTurned += sessionPagesTurned;
|
||||
it->sessions += 1;
|
||||
it->progress = progress;
|
||||
if (walltimeEpoch != 0) {
|
||||
if (it->firstReadEpoch == 0) it->firstReadEpoch = walltimeEpoch;
|
||||
it->lastReadEpoch = walltimeEpoch;
|
||||
}
|
||||
|
||||
globalTotalSeconds += sessionSeconds;
|
||||
globalTotalSessions += 1;
|
||||
globalTotalPagesTurned += sessionPagesTurned;
|
||||
}
|
||||
|
||||
const BookReadingStats* ReadingStatsStore::findBook(const std::string& docId) const {
|
||||
auto it = std::find_if(books.begin(), books.end(),
|
||||
[&docId](const BookReadingStats& b) { return b.docId == docId; });
|
||||
return it == books.end() ? nullptr : &*it;
|
||||
}
|
||||
|
||||
bool ReadingStatsStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveReadingStats(*this, READING_STATS_FILE);
|
||||
}
|
||||
|
||||
bool ReadingStatsStore::loadFromFile() {
|
||||
if (!Storage.exists(READING_STATS_FILE)) {
|
||||
return false;
|
||||
}
|
||||
String json = Storage.readFile(READING_STATS_FILE);
|
||||
if (json.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return JsonSettingsIO::loadReadingStats(*this, json.c_str());
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Per-book reading statistics. Keyed by KOReader document hash (or filename
|
||||
// hash fallback) so a renamed/moved file keeps its history.
|
||||
//
|
||||
// Phase-1 schema is intentionally narrow: aggregate counters plus first/last
|
||||
// timestamps. Day-bucket history for sparklines/heatmaps lands in phase 2.
|
||||
struct BookReadingStats {
|
||||
std::string docId;
|
||||
std::string title;
|
||||
std::string author;
|
||||
uint32_t totalSeconds = 0; // idle-clamped, sum across all sessions
|
||||
uint32_t pagesTurned = 0; // forward + backward
|
||||
uint32_t sessions = 0; // session-open count
|
||||
// 0 if HalClock was never synced when the session ran. Treat as "unknown".
|
||||
time_t firstReadEpoch = 0;
|
||||
time_t lastReadEpoch = 0;
|
||||
uint8_t progress = 0; // 0-100, snapshot of last known progress
|
||||
bool finished = false; // user-marked finished (Phase 1: always false)
|
||||
};
|
||||
|
||||
class ReadingStatsStore;
|
||||
namespace JsonSettingsIO {
|
||||
bool loadReadingStats(ReadingStatsStore& store, const char* json);
|
||||
} // namespace JsonSettingsIO
|
||||
|
||||
// Singleton store for per-book + global reading stats.
|
||||
//
|
||||
// Persistence model (mirrors RecentBooksStore):
|
||||
// /.crosspoint/reading-stats.json — one file, all books + global counters
|
||||
//
|
||||
// One file is fine for phase 1: ESP32 memory + SD seek cost both favour a
|
||||
// single small JSON over per-book files. We'll split if it ever grows too
|
||||
// large (likely never — 50 books * ~120 bytes ≈ 6 KB).
|
||||
class ReadingStatsStore {
|
||||
static ReadingStatsStore instance;
|
||||
|
||||
std::vector<BookReadingStats> books;
|
||||
|
||||
// Global aggregates — sum across all books.
|
||||
uint32_t globalTotalSeconds = 0;
|
||||
uint32_t globalTotalSessions = 0;
|
||||
uint32_t globalTotalPagesTurned = 0;
|
||||
|
||||
friend bool JsonSettingsIO::loadReadingStats(ReadingStatsStore&, const char*);
|
||||
|
||||
public:
|
||||
static ReadingStatsStore& getInstance() { return instance; }
|
||||
|
||||
// Apply a finished session to the store. Creates a per-book entry on first
|
||||
// use. Increments aggregate counters. Updates first/last epoch when
|
||||
// walltimeEpoch != 0 (HalClock was synced). Caller is responsible for
|
||||
// calling saveToFile() — we don't auto-persist on every page turn.
|
||||
void recordSession(const std::string& docId, const std::string& title, const std::string& author,
|
||||
uint32_t sessionSeconds, uint32_t sessionPagesTurned, uint8_t progress, time_t walltimeEpoch);
|
||||
|
||||
// Lookup by document hash; returns nullptr if unknown.
|
||||
const BookReadingStats* findBook(const std::string& docId) const;
|
||||
|
||||
const std::vector<BookReadingStats>& getBooks() const { return books; }
|
||||
uint32_t getGlobalTotalSeconds() const { return globalTotalSeconds; }
|
||||
uint32_t getGlobalTotalSessions() const { return globalTotalSessions; }
|
||||
uint32_t getGlobalTotalPagesTurned() const { return globalTotalPagesTurned; }
|
||||
size_t getBookCount() const { return books.size(); }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
};
|
||||
|
||||
#define READING_STATS ReadingStatsStore::getInstance()
|
||||
@@ -30,10 +30,12 @@
|
||||
#include "FinishedBookActivity.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderDocumentId.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "QrDisplayActivity.h"
|
||||
#include "ReaderActivity.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "ReadingSessionTracker.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "StarredPagesActivity.h"
|
||||
@@ -324,6 +326,13 @@ void EpubReaderActivity::onEnter() {
|
||||
bookParagraphAlignmentOverride = currentBook.paragraphAlignmentOverride;
|
||||
logReaderMemSnapshot("onEnter_after_recent_books");
|
||||
|
||||
// Start a reading-stats session. We use the cheap filename-based hash here:
|
||||
// computing the content hash would re-read the file on every reader open,
|
||||
// and a renamed book getting a new stats entry is acceptable — it'll still
|
||||
// accumulate going forward.
|
||||
globalReadingSessionTracker().begin(KOReaderDocumentId::calculateFromFilename(epub->getPath()), epub->getTitle(),
|
||||
epub->getAuthor());
|
||||
|
||||
// Trigger first update
|
||||
logReaderMemSnapshot("onEnter_before_request_update");
|
||||
requestUpdate();
|
||||
@@ -334,6 +343,11 @@ void EpubReaderActivity::onExit() {
|
||||
Activity::onExit();
|
||||
logReaderMemSnapshot("onExit_before_release");
|
||||
|
||||
// Flush the reading-stats session before tearing down the epub: end() needs
|
||||
// no live epub reference and persists the JSON. Sleep paths that bypass
|
||||
// onExit() still end up here on resume because the activity is recreated.
|
||||
globalReadingSessionTracker().end();
|
||||
|
||||
// Save bookmarks before exit
|
||||
bookmarkStore.save();
|
||||
if (epub) {
|
||||
@@ -1537,6 +1551,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
preRenderedPage.ready = false;
|
||||
usePreRenderedBuffer = true;
|
||||
sessionPagesAdvanced++;
|
||||
globalReadingSessionTracker().onPageTurn();
|
||||
lastPageTurnTime = millis();
|
||||
requestUpdate();
|
||||
return;
|
||||
@@ -1546,6 +1561,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
return;
|
||||
}
|
||||
sessionPagesAdvanced++;
|
||||
globalReadingSessionTracker().onPageTurn();
|
||||
preRenderedPage.ready = false;
|
||||
requestUpdate();
|
||||
}
|
||||
@@ -1921,6 +1937,7 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
|
||||
LOG_ERR("ERS", "Could not save progress!");
|
||||
return;
|
||||
}
|
||||
globalReadingSessionTracker().updateProgress(percent);
|
||||
LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d (%d%%)", spineIndex, currentPage, percent);
|
||||
}
|
||||
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "ReadingStatsActivity.h"
|
||||
|
||||
#include <Arduino.h> // millis()
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ReadingSessionTracker.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// "1h 23m" / "23m 45s" / "12s" — Phase 1 keeps it compact so a long row fits
|
||||
// the right column without truncation on the X3's narrow screen.
|
||||
std::string formatDuration(uint32_t totalSeconds) {
|
||||
const uint32_t h = totalSeconds / 3600;
|
||||
const uint32_t m = (totalSeconds % 3600) / 60;
|
||||
const uint32_t s = totalSeconds % 60;
|
||||
char buf[24];
|
||||
if (h > 0) {
|
||||
snprintf(buf, sizeof(buf), "%uh %02um", h, m);
|
||||
} else if (m > 0) {
|
||||
snprintf(buf, sizeof(buf), "%um %02us", m, s);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%us", s);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReadingStatsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void ReadingStatsActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
// If a reading session happens to be live (e.g. a future entry point lets
|
||||
// the user pop this screen mid-read), tick at most once per second so
|
||||
// "this session" moves visibly without hammering the e-ink panel.
|
||||
if (globalReadingSessionTracker().isActive()) {
|
||||
const uint32_t now = millis();
|
||||
if (now - lastLiveRefreshMs >= 1000) {
|
||||
lastLiveRefreshMs = now;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReadingStatsActivity::render(RenderLock&&) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, /*hasBottomHints=*/true, /*hasSideHints=*/false);
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
GUI.drawHeader(renderer,
|
||||
Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
tr(STR_READING_STATS), nullptr);
|
||||
|
||||
const int leftX = contentRect.x + metrics.verticalSpacing * 3;
|
||||
const int valueX = contentRect.x + contentRect.width / 2;
|
||||
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int rowStep = lineH + 2;
|
||||
const int subHeaderHeight = lineH + 6;
|
||||
int y = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
|
||||
auto drawSection = [&](const char* title) {
|
||||
GUI.drawSubHeader(renderer, Rect{contentRect.x, y, contentRect.width, subHeaderHeight}, title);
|
||||
y += subHeaderHeight + 2;
|
||||
};
|
||||
auto drawRow = [&](const char* label, const std::string& value) {
|
||||
renderer.drawText(UI_10_FONT_ID, leftX, y, label, true, EpdFontFamily::BOLD);
|
||||
renderer.drawText(UI_10_FONT_ID, valueX, y, value.c_str());
|
||||
y += rowStep;
|
||||
};
|
||||
|
||||
const auto& store = READING_STATS;
|
||||
auto& tracker = globalReadingSessionTracker();
|
||||
|
||||
// ---- Live session (only if currently reading) ----
|
||||
if (tracker.isActive()) {
|
||||
drawSection(tr(STR_READING_STATS_CURRENT_SESSION));
|
||||
drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds()));
|
||||
drawRow(tr(STR_READING_STATS_PAGES), std::to_string(tracker.getLivePages()));
|
||||
}
|
||||
|
||||
// ---- All time ----
|
||||
drawSection(tr(STR_READING_STATS_TOTAL_TIME));
|
||||
if (store.getGlobalTotalSeconds() == 0) {
|
||||
drawRow("", tr(STR_READING_STATS_NO_DATA));
|
||||
} else {
|
||||
drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds()));
|
||||
drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(store.getGlobalTotalSessions()));
|
||||
drawRow(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned()));
|
||||
drawRow(tr(STR_READING_STATS_BOOKS), std::to_string(store.getBookCount()));
|
||||
}
|
||||
|
||||
// ---- Top books (up to 3 by total time) ----
|
||||
if (!store.getBooks().empty()) {
|
||||
std::vector<const BookReadingStats*> sorted;
|
||||
sorted.reserve(store.getBooks().size());
|
||||
for (const auto& b : store.getBooks()) sorted.push_back(&b);
|
||||
std::sort(sorted.begin(), sorted.end(),
|
||||
[](const BookReadingStats* a, const BookReadingStats* b) { return a->totalSeconds > b->totalSeconds; });
|
||||
|
||||
drawSection(tr(STR_READING_STATS_TOP_BOOKS));
|
||||
const size_t shown = std::min<size_t>(sorted.size(), 3);
|
||||
for (size_t i = 0; i < shown; ++i) {
|
||||
const auto* b = sorted[i];
|
||||
// Use the title when known; fall back to docId so the row is never
|
||||
// empty even before metadata is recorded.
|
||||
std::string label = b->title.empty() ? b->docId : b->title;
|
||||
// Trim to fit; 22 chars keeps it in the left column at UI_10.
|
||||
if (label.size() > 22) {
|
||||
label.resize(22);
|
||||
label += "…";
|
||||
}
|
||||
drawRow(label.c_str(), formatDuration(b->totalSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
// Phase-1 reading-stats screen. Read-only summary of the per-book and global
|
||||
// reading counters collected by ReadingSessionTracker. Mirrors the layout of
|
||||
// SystemInformationActivity so it slots into the existing settings flow with
|
||||
// no new theme work. Future phases will add per-book drill-in, day-by-day
|
||||
// sparklines, and a web-frontend dashboard.
|
||||
class ReadingStatsActivity final : public Activity {
|
||||
public:
|
||||
explicit ReadingStatsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("ReadingStats", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// Last millis() at which we refreshed the live "this session" block.
|
||||
// We only redraw once per second to avoid hammering the e-ink panel.
|
||||
uint32_t lastLiveRefreshMs = 0;
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "ReadingStatsActivity.h"
|
||||
#include "SdFirmwareUpdateActivity.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
#include "SyncTimeActivity.h"
|
||||
@@ -52,6 +53,8 @@ std::unique_ptr<Activity> createActivityForAction(SettingAction action, GfxRende
|
||||
return std::make_unique<SyncTimeActivity>(renderer, mappedInput);
|
||||
case SettingAction::DetectTimezone:
|
||||
return std::make_unique<DetectTimezoneActivity>(renderer, mappedInput);
|
||||
case SettingAction::ReadingStats:
|
||||
return std::make_unique<ReadingStatsActivity>(renderer, mappedInput);
|
||||
case SettingAction::Submenu:
|
||||
case SettingAction::None:
|
||||
return nullptr;
|
||||
|
||||
@@ -31,6 +31,7 @@ enum class SettingAction {
|
||||
DetectTimezone,
|
||||
SyncTime,
|
||||
Weather,
|
||||
ReadingStats,
|
||||
Submenu,
|
||||
};
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ void SettingsActivity::onEnter() {
|
||||
addToMoved(systemSettings, lastSystemSub,
|
||||
std::move(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo)
|
||||
.withSubcategory(StrId::STR_MENU_SYS_SYSTEM)));
|
||||
addToMoved(systemSettings, lastSystemSub,
|
||||
std::move(SettingInfo::Action(StrId::STR_READING_STATS, SettingAction::ReadingStats)
|
||||
.withSubcategory(StrId::STR_MENU_SYS_SYSTEM)));
|
||||
|
||||
SettingInfo::prepareSubmenus(displaySettings, submenuData);
|
||||
SettingInfo::prepareSubmenus(readerSettings, submenuData);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SdCardFontSystem.h"
|
||||
#include "SilentRestart.h"
|
||||
@@ -384,6 +385,7 @@ void setup() {
|
||||
HalClock::restore();
|
||||
RECENT_BOOKS.loadFromFile();
|
||||
GLOBAL_BOOKMARKS.load();
|
||||
READING_STATS.loadFromFile();
|
||||
|
||||
if (recoveryFirmwareMode) {
|
||||
// Skip normal home/reader routing: jump straight into the SD firmware picker.
|
||||
|
||||
Reference in New Issue
Block a user