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
@@ -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);