Phase 1 - basic architecture

This commit is contained in:
jpirnay
2026-05-19 23:49:42 +02:00
parent aeedc0b3ca
commit 65418c2606
14 changed files with 583 additions and 0 deletions
+8
View File
@@ -529,6 +529,14 @@ STR_OVERLAY_READING_PROGRESS: "Reading progress: Page %lu/%u - %.0f%%"
STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu"
STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read"
STR_SYSTEM_INFO: "System Information"
STR_READING_STATS: "Reading Stats"
STR_READING_STATS_TOTAL_TIME: "Total time"
STR_READING_STATS_SESSIONS: "Sessions"
STR_READING_STATS_PAGES: "Pages turned"
STR_READING_STATS_BOOKS: "Books tracked"
STR_READING_STATS_CURRENT_SESSION: "This session"
STR_READING_STATS_NO_DATA: "No reading recorded yet"
STR_READING_STATS_TOP_BOOKS: "Top books"
STR_LOAD_XTC_FAILED: "Failed to load XTC file"
STR_LOAD_EPUB_FAILED: "Failed to load EPUB file"
STR_FW_VERSION: "FW version"
+63
View File
@@ -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;
}
+5
View File
@@ -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
+91
View File
@@ -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);
}
+84
View File
@@ -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();
+73
View File
@@ -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());
}
+74
View File
@@ -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;
+1
View File
@@ -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);
+2
View File
@@ -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.