Phase 5 - card based layout
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
#include "RecentBooksStore.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "StarredPagesActivity.h"
|
||||
#include "activities/settings/ReadingStatsBookDetailActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ScreenshotUtil.h"
|
||||
@@ -723,6 +724,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::READING_STATS: {
|
||||
// Jump to this book's detail screen using the same filename-hash docId
|
||||
// the session was opened with. The in-flight session's time isn't
|
||||
// visible here — it lands in the store only when end() runs on reader
|
||||
// exit. For a brand-new book that's never been finished a session yet
|
||||
// the screen will show "no data"; that's accurate.
|
||||
if (!epub) break;
|
||||
startActivityForResult(std::make_unique<ReadingStatsBookDetailActivity>(
|
||||
renderer, mappedInput, KOReaderDocumentId::calculateFromFilename(epub->getPath())),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::MARK_AS_READ: {
|
||||
if (!epub) {
|
||||
break;
|
||||
|
||||
@@ -252,6 +252,8 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPa
|
||||
|
||||
// --- Tools ---
|
||||
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(SettingInfo::Action(StrId::STR_READING_STATS_FOR_THIS_BOOK, SettingAction::None)
|
||||
.withSubmenu(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(
|
||||
SettingInfo::Action(StrId::STR_MARK_AS_READ, SettingAction::None).withSubmenu(StrId::STR_READER_TOOLS));
|
||||
menuItems.push_back(
|
||||
@@ -307,6 +309,8 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
|
||||
return MenuAction::RENDER_BENCHMARK;
|
||||
case StrId::STR_GO_HOME_BUTTON:
|
||||
return MenuAction::GO_HOME;
|
||||
case StrId::STR_READING_STATS_FOR_THIS_BOOK:
|
||||
return MenuAction::READING_STATS;
|
||||
default:
|
||||
return MenuAction::NONE;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ class EpubReaderMenuActivity final : public MenuListActivity {
|
||||
STAR_PAGE,
|
||||
MARK_AS_READ,
|
||||
DELETE_CACHE,
|
||||
RENDER_BENCHMARK
|
||||
RENDER_BENCHMARK,
|
||||
READING_STATS,
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
|
||||
@@ -5,20 +5,22 @@
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ReadingSessionTracker.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "ReadingStatsBookListActivity.h"
|
||||
#include "components/CardLayout.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.
|
||||
// "1h 23m" / "23m 45s" / "12s" — compact so a long row fits on the X3.
|
||||
std::string formatDuration(uint32_t totalSeconds) {
|
||||
const uint32_t h = totalSeconds / 3600;
|
||||
const uint32_t m = (totalSeconds % 3600) / 60;
|
||||
@@ -34,6 +36,19 @@ std::string formatDuration(uint32_t totalSeconds) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Pages per minute, rounded to 1 decimal. Returns "—" when there's not enough
|
||||
// data (fewer than a minute total) so we don't display "120.0 ppm" when only
|
||||
// a handful of seconds have been recorded.
|
||||
std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) {
|
||||
if (seconds < 60 || pages == 0) {
|
||||
return tr(STR_READING_STATS_UNKNOWN);
|
||||
}
|
||||
const float ppm = (pages * 60.0f) / seconds;
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%.1f", ppm);
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReadingStatsActivity::onEnter() {
|
||||
@@ -53,8 +68,7 @@ void ReadingStatsActivity::loop() {
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
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
|
||||
// If a reading session happens to be live, 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();
|
||||
@@ -75,100 +89,87 @@ void ReadingStatsActivity::render(RenderLock&&) {
|
||||
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 int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
CardLayout::Config cfg;
|
||||
cfg.outerMarginX = metrics.verticalSpacing * 2;
|
||||
CardLayout layout(renderer, contentRect, startY, cfg);
|
||||
|
||||
const auto& store = READING_STATS;
|
||||
auto& tracker = globalReadingSessionTracker();
|
||||
|
||||
// ---- Live session (only if currently reading) ----
|
||||
// ---- Live session card ----
|
||||
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()));
|
||||
layout.card(tr(STR_READING_STATS_CURRENT_SESSION), [&](CardLayout::Body& b) {
|
||||
b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(tracker.getLiveSeconds()));
|
||||
b.rowLR(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()));
|
||||
|
||||
// Streaks: only meaningful when at least one wall-clocked session exists.
|
||||
if (!store.getGlobalDays().empty()) {
|
||||
const uint16_t today = currentLocalDayIndex();
|
||||
const uint16_t current = store.computeCurrentStreak(today);
|
||||
const uint16_t longest = store.computeLongestStreak();
|
||||
char buf[24];
|
||||
snprintf(buf, sizeof(buf), "%u%s / %u%s", current, tr(STR_READING_STATS_DAYS_UNIT), longest,
|
||||
tr(STR_READING_STATS_DAYS_UNIT));
|
||||
drawRow(tr(STR_READING_STATS_STREAK), buf);
|
||||
// ---- All-time card ----
|
||||
layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) {
|
||||
if (store.getGlobalTotalSeconds() == 0) {
|
||||
b.centeredMessage(tr(STR_READING_STATS_NO_DATA));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 30-day sparkline ----
|
||||
// Renders one bar per day for the last 30 local days ending at "today".
|
||||
// Height of each bar is proportional to that day's seconds vs. the maximum
|
||||
// seen in the window. Days with no reading get a flat 1px baseline so the
|
||||
// gap pattern stays visible. Drawn only when the clock is synced — without
|
||||
// it we have no "today" to anchor the window against.
|
||||
// 4-cell stat grid: sessions / books / current streak / longest streak.
|
||||
// Streaks read "—" when the clock has never been wall-anchored.
|
||||
const uint16_t today = currentLocalDayIndex();
|
||||
const bool haveStreak = today != 0 && !store.getGlobalDays().empty();
|
||||
const std::string curStreak =
|
||||
haveStreak ? std::to_string(store.computeCurrentStreak(today)) : std::string(tr(STR_READING_STATS_UNKNOWN));
|
||||
const std::string maxStreak =
|
||||
haveStreak ? std::to_string(store.computeLongestStreak()) : std::string(tr(STR_READING_STATS_UNKNOWN));
|
||||
b.statGrid({{{std::to_string(store.getGlobalTotalSessions()), tr(STR_READING_STATS_SESSIONS)},
|
||||
{std::to_string(store.getBookCount()), tr(STR_READING_STATS_BOOKS)},
|
||||
{curStreak, tr(STR_READING_STATS_STREAK)},
|
||||
{maxStreak, tr(STR_READING_STATS_LONGEST)}}});
|
||||
|
||||
b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(store.getGlobalTotalSeconds()));
|
||||
b.rowLR(tr(STR_READING_STATS_PAGES), std::to_string(store.getGlobalTotalPagesTurned()));
|
||||
b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN),
|
||||
formatPagesPerMin(store.getGlobalTotalPagesTurned(), store.getGlobalTotalSeconds()));
|
||||
});
|
||||
|
||||
// ---- 30-day sparkline card ----
|
||||
const uint16_t today = currentLocalDayIndex();
|
||||
if (today != 0 && !store.getGlobalDays().empty()) {
|
||||
drawSection(tr(STR_READING_STATS_LAST_30D));
|
||||
constexpr int kSparkDays = 30;
|
||||
constexpr int kSparkHeight = 38;
|
||||
constexpr int kBarGap = 1;
|
||||
const int sparkLeft = leftX;
|
||||
const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3;
|
||||
const int sparkWidth = std::max(0, sparkRight - sparkLeft);
|
||||
const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays);
|
||||
const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1);
|
||||
const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2;
|
||||
const int sparkOriginY = y;
|
||||
layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) {
|
||||
constexpr int kSparkDays = 30;
|
||||
constexpr int kSparkHeight = 32;
|
||||
constexpr int kBarGap = 1;
|
||||
const int innerWidth = b.innerWidth();
|
||||
const int innerLeft = b.innerLeft();
|
||||
const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays);
|
||||
const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1);
|
||||
const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2;
|
||||
const int sparkOriginY = b.currentY();
|
||||
|
||||
uint32_t maxSeconds = 1;
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d =
|
||||
(today > static_cast<uint16_t>(kSparkDays - 1 - i)) ? static_cast<uint16_t>(today - (kSparkDays - 1 - i)) : 0;
|
||||
const uint32_t s = store.getSecondsForDay(d);
|
||||
if (s > maxSeconds) maxSeconds = s;
|
||||
}
|
||||
uint32_t maxSeconds = 1;
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = store.getSecondsForDay(d);
|
||||
if (s > maxSeconds) maxSeconds = s;
|
||||
}
|
||||
|
||||
// Baseline (axis) — 1px line under the bars so the visual grouping reads
|
||||
// as a chart even when most days are empty.
|
||||
renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan, sparkOriginY + kSparkHeight,
|
||||
true);
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d =
|
||||
(today > static_cast<uint16_t>(kSparkDays - 1 - i)) ? static_cast<uint16_t>(today - (kSparkDays - 1 - i)) : 0;
|
||||
const uint32_t s = store.getSecondsForDay(d);
|
||||
const int barX = sparkOriginX + i * (barWidth + kBarGap);
|
||||
// 1px minimum so empty days still tick on the axis.
|
||||
const int h = s == 0 ? 1 : std::max<int>(2, (s * kSparkHeight) / maxSeconds);
|
||||
renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true);
|
||||
}
|
||||
y += kSparkHeight + 6;
|
||||
renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan,
|
||||
sparkOriginY + kSparkHeight, true);
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = store.getSecondsForDay(d);
|
||||
const int barX = sparkOriginX + i * (barWidth + kBarGap);
|
||||
const int h = s == 0 ? 1 : std::max<int>(2, (s * kSparkHeight) / maxSeconds);
|
||||
renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true);
|
||||
}
|
||||
b.advance(kSparkHeight + 2);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Top books (up to 3 by total time) ----
|
||||
// ---- Top books card ----
|
||||
if (!store.getBooks().empty()) {
|
||||
std::vector<const BookReadingStats*> sorted;
|
||||
sorted.reserve(store.getBooks().size());
|
||||
@@ -176,20 +177,31 @@ void ReadingStatsActivity::render(RenderLock&&) {
|
||||
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 += "…";
|
||||
layout.card(tr(STR_READING_STATS_TOP_BOOKS), [&](CardLayout::Body& b) {
|
||||
const int ellipsisWidth = renderer.getTextWidth(UI_10_FONT_ID, "…");
|
||||
constexpr int kTitleGap = 8;
|
||||
const size_t shown = std::min<size_t>(sorted.size(), 3);
|
||||
const int innerLeft = b.innerLeft();
|
||||
const int innerRight = b.innerRight();
|
||||
for (size_t i = 0; i < shown; ++i) {
|
||||
const auto* bk = sorted[i];
|
||||
const std::string time = formatDuration(bk->totalSeconds);
|
||||
const int timeWidth = renderer.getTextWidth(UI_10_FONT_ID, time.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, innerRight - timeWidth, b.currentY(), time.c_str());
|
||||
|
||||
std::string label = bk->title.empty() ? bk->docId : bk->title;
|
||||
const int maxLabelWidth = (innerRight - timeWidth - kTitleGap) - innerLeft;
|
||||
if (maxLabelWidth > 0 && renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) > maxLabelWidth) {
|
||||
while (!label.empty() &&
|
||||
renderer.getTextWidth(UI_10_FONT_ID, label.c_str()) + ellipsisWidth > maxLabelWidth) {
|
||||
label.pop_back();
|
||||
}
|
||||
label += "…";
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID, innerLeft, b.currentY(), label.c_str(), true, EpdFontFamily::BOLD);
|
||||
b.advance(b.rowStep());
|
||||
}
|
||||
drawRow(label.c_str(), formatDuration(b->totalSeconds));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const char* btn2 = store.getBooks().empty() ? "" : tr(STR_READING_STATS_BOOK_LIST);
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <utility>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ReadingStats.h"
|
||||
#include "components/CardLayout.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -61,6 +64,16 @@ std::string formatDateOrRelative(time_t epoch) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string formatPagesPerMin(uint32_t pages, uint32_t seconds) {
|
||||
if (seconds < 60 || pages == 0) {
|
||||
return tr(STR_READING_STATS_UNKNOWN);
|
||||
}
|
||||
const float ppm = (pages * 60.0f) / seconds;
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%.1f", ppm);
|
||||
return buf;
|
||||
}
|
||||
|
||||
uint32_t secondsForDayIn(const std::vector<DayBucket>& days, uint16_t dayIndex) {
|
||||
if (dayIndex == 0) return 0;
|
||||
auto it = std::lower_bound(days.begin(), days.end(), dayIndex,
|
||||
@@ -91,9 +104,8 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) {
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Header: title up to ~28 chars (theme will further clip if the screen is
|
||||
// narrow). Fallback to docId so we still produce a usable screen if the
|
||||
// book's metadata was never recorded.
|
||||
// Header — title (truncated) and author. Title fallback to docId so we
|
||||
// still produce a usable screen if the book's metadata was never recorded.
|
||||
std::string headerTitle = (book && !book->title.empty()) ? book->title : docId;
|
||||
if (headerTitle.size() > 28) {
|
||||
headerTitle.resize(28);
|
||||
@@ -103,83 +115,78 @@ void ReadingStatsBookDetailActivity::render(RenderLock&&) {
|
||||
Rect{contentRect.x, contentRect.y + metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
headerTitle.c_str(), book && !book->author.empty() ? book->author.c_str() : 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 int startY = contentRect.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
CardLayout::Config cfg;
|
||||
cfg.outerMarginX = metrics.verticalSpacing * 2;
|
||||
CardLayout layout(renderer, contentRect, startY, cfg);
|
||||
|
||||
if (!book) {
|
||||
// The book may have been removed from the store between the list and
|
||||
// the detail screen (e.g. a future "clear stats for this book" action).
|
||||
// Show a placeholder rather than crash on a null deref.
|
||||
drawRow("", tr(STR_READING_STATS_NO_DATA));
|
||||
layout.card(nullptr, [](CardLayout::Body& b) { b.centeredMessage(tr(STR_READING_STATS_NO_DATA)); });
|
||||
} else {
|
||||
drawSection(tr(STR_READING_STATS_TOTAL_TIME));
|
||||
drawRow(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds));
|
||||
drawRow(tr(STR_READING_STATS_SESSIONS), std::to_string(book->sessions));
|
||||
drawRow(tr(STR_READING_STATS_PAGES), std::to_string(book->pagesTurned));
|
||||
if (book->sessions > 0) {
|
||||
drawRow(tr(STR_READING_STATS_AVG_SESSION), formatDuration(book->totalSeconds / book->sessions));
|
||||
}
|
||||
// ---- Summary card: 4-cell grid (sessions / pages / avg / progress) ----
|
||||
const std::string avgValue = book->sessions > 0 ? formatDuration(book->totalSeconds / book->sessions)
|
||||
: std::string(tr(STR_READING_STATS_UNKNOWN));
|
||||
char pctBuf[8];
|
||||
snprintf(pctBuf, sizeof(pctBuf), "%u%%", book->progress);
|
||||
drawRow(tr(STR_READING_STATS_PROGRESS), pctBuf);
|
||||
const std::string pctStr(pctBuf);
|
||||
layout.card(nullptr, [&](CardLayout::Body& b) {
|
||||
b.statGrid({{{std::to_string(book->sessions), tr(STR_READING_STATS_SESSIONS)},
|
||||
{std::to_string(book->pagesTurned), tr(STR_READING_STATS_PAGES)},
|
||||
{avgValue, tr(STR_READING_STATS_AVG_SESSION)},
|
||||
{pctStr, tr(STR_READING_STATS_PROGRESS)}}});
|
||||
});
|
||||
|
||||
drawSection(tr(STR_READING_STATS_FIRST_READ));
|
||||
drawRow(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch));
|
||||
drawRow(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch));
|
||||
// ---- Time card ----
|
||||
layout.card(tr(STR_READING_STATS_TOTAL_TIME), [&](CardLayout::Body& b) {
|
||||
b.rowLR(tr(STR_READING_STATS_TOTAL_TIME), formatDuration(book->totalSeconds));
|
||||
b.rowLR(tr(STR_READING_STATS_PAGES_PER_MIN), formatPagesPerMin(book->pagesTurned, book->totalSeconds));
|
||||
});
|
||||
|
||||
// Per-book 30-day sparkline. Identical algorithm to the main screen but
|
||||
// reads from this book's own day vector. Hidden when no wall-clocked day
|
||||
// exists or the clock isn't synced — otherwise the bars would be
|
||||
// meaningless ("we don't know what day this was").
|
||||
// ---- History card ----
|
||||
layout.card(tr(STR_READING_STATS_HISTORY), [&](CardLayout::Body& b) {
|
||||
b.rowLR(tr(STR_READING_STATS_FIRST_READ), formatDateOrRelative(book->firstReadEpoch));
|
||||
b.rowLR(tr(STR_READING_STATS_LAST_READ), formatDateOrRelative(book->lastReadEpoch));
|
||||
});
|
||||
|
||||
// ---- Per-book sparkline (only when clock-anchored data exists) ----
|
||||
const uint16_t today = currentLocalDayIndex();
|
||||
if (today != 0 && !book->days.empty()) {
|
||||
drawSection(tr(STR_READING_STATS_LAST_30D));
|
||||
constexpr int kSparkDays = 30;
|
||||
constexpr int kSparkHeight = 38;
|
||||
constexpr int kBarGap = 1;
|
||||
const int sparkLeft = leftX;
|
||||
const int sparkRight = contentRect.x + contentRect.width - metrics.verticalSpacing * 3;
|
||||
const int sparkWidth = std::max(0, sparkRight - sparkLeft);
|
||||
const int barWidth = std::max(2, (sparkWidth - (kSparkDays - 1) * kBarGap) / kSparkDays);
|
||||
const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1);
|
||||
const int sparkOriginX = sparkLeft + (sparkWidth - totalSpan) / 2;
|
||||
const int sparkOriginY = y;
|
||||
layout.card(tr(STR_READING_STATS_LAST_30D), [&](CardLayout::Body& b) {
|
||||
constexpr int kSparkDays = 30;
|
||||
constexpr int kSparkHeight = 32;
|
||||
constexpr int kBarGap = 1;
|
||||
const int innerWidth = b.innerWidth();
|
||||
const int innerLeft = b.innerLeft();
|
||||
const int barWidth = std::max(2, (innerWidth - (kSparkDays - 1) * kBarGap) / kSparkDays);
|
||||
const int totalSpan = barWidth * kSparkDays + kBarGap * (kSparkDays - 1);
|
||||
const int sparkOriginX = innerLeft + (innerWidth - totalSpan) / 2;
|
||||
const int sparkOriginY = b.currentY();
|
||||
|
||||
uint32_t maxSeconds = 1;
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = secondsForDayIn(book->days, d);
|
||||
if (s > maxSeconds) maxSeconds = s;
|
||||
}
|
||||
uint32_t maxSeconds = 1;
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = secondsForDayIn(book->days, d);
|
||||
if (s > maxSeconds) maxSeconds = s;
|
||||
}
|
||||
|
||||
renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan,
|
||||
sparkOriginY + kSparkHeight, true);
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = secondsForDayIn(book->days, d);
|
||||
const int barX = sparkOriginX + i * (barWidth + kBarGap);
|
||||
const int h = s == 0 ? 1 : std::max<int>(2, (s * kSparkHeight) / maxSeconds);
|
||||
renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true);
|
||||
}
|
||||
y += kSparkHeight + 6;
|
||||
renderer.drawLine(sparkOriginX, sparkOriginY + kSparkHeight, sparkOriginX + totalSpan,
|
||||
sparkOriginY + kSparkHeight, true);
|
||||
for (int i = 0; i < kSparkDays; ++i) {
|
||||
const uint16_t d = (today > static_cast<uint16_t>(kSparkDays - 1 - i))
|
||||
? static_cast<uint16_t>(today - (kSparkDays - 1 - i))
|
||||
: 0;
|
||||
const uint32_t s = secondsForDayIn(book->days, d);
|
||||
const int barX = sparkOriginX + i * (barWidth + kBarGap);
|
||||
const int h = s == 0 ? 1 : std::max<int>(2, (s * kSparkHeight) / maxSeconds);
|
||||
renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true);
|
||||
}
|
||||
b.advance(kSparkHeight + 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "CardLayout.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "fontIds.h"
|
||||
|
||||
CardLayout::CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg)
|
||||
: renderer_(renderer), contentRect_(contentRect), cfg_(cfg), y_(startY) {
|
||||
cardLeft_ = contentRect.x + cfg_.outerMarginX;
|
||||
cardWidth_ = contentRect.width - cfg_.outerMarginX * 2;
|
||||
innerLeft_ = cardLeft_ + cfg_.innerPadX;
|
||||
innerRight_ = cardLeft_ + cardWidth_ - cfg_.innerPadX;
|
||||
innerWidth_ = innerRight_ - innerLeft_;
|
||||
lineH_ = renderer_.getLineHeight(UI_10_FONT_ID);
|
||||
rowStep_ = lineH_ + 2;
|
||||
titleH_ = lineH_ + 2;
|
||||
}
|
||||
|
||||
void CardLayout::card(const char* title, const std::function<void(Body&)>& bodyFn) {
|
||||
const int top = y_;
|
||||
const int titleBlock = title ? titleH_ + cfg_.titleGap : 0;
|
||||
const int bodyTop = top + cfg_.innerPadY + titleBlock;
|
||||
int innerY = bodyTop;
|
||||
|
||||
// Body draws directly into the frame buffer; we measure how far it
|
||||
// advanced so the rounded border can be sized exactly to the content.
|
||||
Body body(*this, innerY);
|
||||
bodyFn(body);
|
||||
|
||||
const int bodyHeight = innerY - bodyTop;
|
||||
const int cardHeight = cfg_.innerPadY + titleBlock + bodyHeight + cfg_.innerPadY;
|
||||
renderer_.drawRoundedRect(cardLeft_, top, cardWidth_, cardHeight, 1, cfg_.radius, true);
|
||||
if (title) {
|
||||
renderer_.drawCenteredText(UI_10_FONT_ID, top + cfg_.innerPadY, title, true, EpdFontFamily::BOLD);
|
||||
}
|
||||
y_ = top + cardHeight + cfg_.cardSpacing;
|
||||
}
|
||||
|
||||
void CardLayout::Body::rowLR(const char* label, const std::string& value) {
|
||||
layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_, innerY, label, true, EpdFontFamily::BOLD);
|
||||
const int vw = layout.renderer_.getTextWidth(UI_10_FONT_ID, value.c_str());
|
||||
layout.renderer_.drawText(UI_10_FONT_ID, layout.innerRight_ - vw, innerY, value.c_str());
|
||||
innerY += layout.rowStep_;
|
||||
}
|
||||
|
||||
void CardLayout::Body::statGrid(const std::array<std::pair<std::string, const char*>, 4>& cells) {
|
||||
const int cellW = layout.innerWidth_ / 4;
|
||||
const int valueY = innerY;
|
||||
const int labelY = innerY + layout.lineH_ + 2;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const auto& [value, label] = cells[i];
|
||||
const int cellCenterX = layout.innerLeft_ + cellW * i + cellW / 2;
|
||||
const int vw = layout.renderer_.getTextWidth(UI_12_FONT_ID, value.c_str(), EpdFontFamily::BOLD);
|
||||
const int lw = layout.renderer_.getTextWidth(UI_10_FONT_ID, label);
|
||||
layout.renderer_.drawText(UI_12_FONT_ID, cellCenterX - vw / 2, valueY, value.c_str(), true, EpdFontFamily::BOLD);
|
||||
layout.renderer_.drawText(UI_10_FONT_ID, cellCenterX - lw / 2, labelY, label);
|
||||
if (i < 3) {
|
||||
const int divX = layout.innerLeft_ + cellW * (i + 1);
|
||||
layout.renderer_.drawLine(divX, valueY - 2, divX, labelY + layout.lineH_, true);
|
||||
}
|
||||
}
|
||||
innerY = labelY + layout.lineH_ + 4;
|
||||
}
|
||||
|
||||
void CardLayout::Body::centeredMessage(const char* msg) {
|
||||
const int mw = layout.renderer_.getTextWidth(UI_10_FONT_ID, msg);
|
||||
layout.renderer_.drawText(UI_10_FONT_ID, layout.innerLeft_ + (layout.innerWidth_ - mw) / 2, innerY, msg);
|
||||
innerY += layout.rowStep_;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "components/themes/BaseTheme.h" // for Rect
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
// Reusable "boxed card" layout primitive for stat-style screens.
|
||||
//
|
||||
// A CardLayout owns the vertical cursor on a content rect and renders a
|
||||
// stack of rounded-border cards. Each card has an optional title and a body
|
||||
// composed by the caller via a lambda; the body lambda can call helpers on
|
||||
// the CardBody it receives to draw label/value rows or a 4-cell stat grid.
|
||||
//
|
||||
// Typical usage (Reading Stats screen):
|
||||
//
|
||||
// CardLayout layout(renderer, contentRect, startY);
|
||||
// layout.card("Total time", [&](CardLayout::Body& b) {
|
||||
// b.rowLR("Sessions", "12");
|
||||
// b.rowLR("Pages", "248");
|
||||
// });
|
||||
// layout.card(nullptr, [&](CardLayout::Body& b) {
|
||||
// b.statGrid({{ {"3", "Sess"}, {"7", "Books"}, {"4", "Streak"}, {"9", "Best"} }});
|
||||
// });
|
||||
//
|
||||
// Design notes
|
||||
// - Title-less cards (title == nullptr) are used as "hero" panels where
|
||||
// the grid is itself the headline.
|
||||
// - Card height is computed automatically by tracking how far the body
|
||||
// lambda advanced its internal y cursor. The rounded rect is drawn
|
||||
// LAST so it sits cleanly around the content (it can't clip text
|
||||
// because content padding is fixed at construction time).
|
||||
// - All values use UI_10 font; grid values use UI_12 bold. This is
|
||||
// consistent across stats screens; if a future caller needs another
|
||||
// font scale we'll add an optional config struct.
|
||||
class CardLayout {
|
||||
public:
|
||||
struct Config {
|
||||
int radius = 4;
|
||||
int innerPadX = 10;
|
||||
int innerPadY = 6;
|
||||
int titleGap = 4;
|
||||
int cardSpacing = 6;
|
||||
// Horizontal margin between the content rect and the card's outer
|
||||
// bounds. Defaults to 0; pass theme.verticalSpacing * 2 to line up
|
||||
// with the rest of the system UI on a given theme.
|
||||
int outerMarginX = 0;
|
||||
};
|
||||
|
||||
// Inner-card drawing context handed to card body lambdas. Tracks the
|
||||
// running y cursor inside the card and exposes the same content geometry
|
||||
// (innerLeft / innerRight / innerWidth) needed for custom layouts.
|
||||
class Body {
|
||||
friend class CardLayout;
|
||||
const CardLayout& layout;
|
||||
int& innerY;
|
||||
Body(const CardLayout& layout, int& innerY) : layout(layout), innerY(innerY) {}
|
||||
|
||||
public:
|
||||
int currentY() const { return innerY; }
|
||||
void advance(int dy) { innerY += dy; }
|
||||
int innerLeft() const { return layout.innerLeft_; }
|
||||
int innerRight() const { return layout.innerRight_; }
|
||||
int innerWidth() const { return layout.innerWidth_; }
|
||||
int lineHeight() const { return layout.lineH_; }
|
||||
int rowStep() const { return layout.rowStep_; }
|
||||
|
||||
// Label (bold) left, value right-aligned at innerRight.
|
||||
void rowLR(const char* label, const std::string& value);
|
||||
|
||||
// 4-cell stat grid: large bold value above small label, three 1-px
|
||||
// dividers between cells. Advances y by ~2.5 line heights.
|
||||
void statGrid(const std::array<std::pair<std::string, const char*>, 4>& cells);
|
||||
|
||||
// Centered single-line message (e.g. "No data yet" placeholder).
|
||||
void centeredMessage(const char* msg);
|
||||
};
|
||||
|
||||
CardLayout(GfxRenderer& renderer, Rect contentRect, int startY, Config cfg = {});
|
||||
|
||||
// Render a single card. `bodyFn` receives a `Body&` and may call its
|
||||
// helpers in any order; the card auto-sizes to whatever the body draws.
|
||||
// Pass `title == nullptr` for an untitled hero card.
|
||||
void card(const char* title, const std::function<void(Body&)>& bodyFn);
|
||||
|
||||
// Where the next card would start. Useful for callers that want to know
|
||||
// how much vertical space they've consumed (e.g. to decide whether to
|
||||
// skip a later section that wouldn't fit).
|
||||
int cursorY() const { return y_; }
|
||||
|
||||
private:
|
||||
GfxRenderer& renderer_;
|
||||
Rect contentRect_;
|
||||
Config cfg_;
|
||||
|
||||
int cardLeft_;
|
||||
int cardWidth_;
|
||||
int innerLeft_;
|
||||
int innerRight_;
|
||||
int innerWidth_;
|
||||
int lineH_;
|
||||
int rowStep_;
|
||||
int titleH_;
|
||||
int y_;
|
||||
};
|
||||
Reference in New Issue
Block a user