Phase 5 - card based layout

This commit is contained in:
jpirnay
2026-05-20 00:51:31 +02:00
parent dcbe79aab2
commit b074350d30
8 changed files with 382 additions and 164 deletions
+4 -1
View File
@@ -539,13 +539,16 @@ STR_READING_STATS_NO_DATA: "No reading recorded yet"
STR_READING_STATS_TOP_BOOKS: "Top books"
STR_READING_STATS_STREAK: "Streak"
STR_READING_STATS_LAST_30D: "Last 30 days"
STR_READING_STATS_DAYS_UNIT: "d"
STR_READING_STATS_BOOK_LIST: "All books"
STR_READING_STATS_FIRST_READ: "First read"
STR_READING_STATS_AVG_SESSION: "Avg session"
STR_READING_STATS_PROGRESS: "Progress"
STR_READING_STATS_UNKNOWN: "—"
STR_READING_STATS_LAST_READ: "Last read"
STR_READING_STATS_FOR_THIS_BOOK: "Reading stats"
STR_READING_STATS_LONGEST: "Longest"
STR_READING_STATS_PAGES_PER_MIN: "Pages/min"
STR_READING_STATS_HISTORY: "History"
STR_LOAD_XTC_FAILED: "Failed to load XTC file"
STR_LOAD_EPUB_FAILED: "Failed to load EPUB file"
STR_FW_VERSION: "FW version"
@@ -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,
+108 -96
View File
@@ -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);
});
}
}
+69
View File
@@ -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_;
}
+109
View File
@@ -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 = Config());
// 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_;
};