diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index f5884fa3..a797e8f9 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 93149744..f6a4b84e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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( + renderer, mappedInput, KOReaderDocumentId::calculateFromFilename(epub->getPath())), + [this](const ActivityResult&) { requestUpdate(); }); + break; + } case EpubReaderMenuActivity::MenuAction::MARK_AS_READ: { if (!epub) { break; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8d763cb3..0ce18305 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -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; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 23fd41be..ed713e0a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -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, diff --git a/src/activities/settings/ReadingStatsActivity.cpp b/src/activities/settings/ReadingStatsActivity.cpp index ee102288..00115b42 100644 --- a/src/activities/settings/ReadingStatsActivity.cpp +++ b/src/activities/settings/ReadingStatsActivity.cpp @@ -5,20 +5,22 @@ #include #include +#include #include +#include #include #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(kSparkDays - 1 - i)) ? static_cast(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(kSparkDays - 1 - i)) + ? static_cast(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(kSparkDays - 1 - i)) ? static_cast(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(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(kSparkDays - 1 - i)) + ? static_cast(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(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 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(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(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); diff --git a/src/activities/settings/ReadingStatsBookDetailActivity.cpp b/src/activities/settings/ReadingStatsBookDetailActivity.cpp index ef0eb50a..fad0d9d8 100644 --- a/src/activities/settings/ReadingStatsBookDetailActivity.cpp +++ b/src/activities/settings/ReadingStatsBookDetailActivity.cpp @@ -5,11 +5,14 @@ #include #include +#include #include #include +#include #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& 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(kSparkDays - 1 - i)) - ? static_cast(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(kSparkDays - 1 - i)) + ? static_cast(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(kSparkDays - 1 - i)) - ? static_cast(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(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(kSparkDays - 1 - i)) + ? static_cast(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(2, (s * kSparkHeight) / maxSeconds); + renderer.fillRect(barX, sparkOriginY + kSparkHeight - h, barWidth, h, true); + } + b.advance(kSparkHeight + 2); + }); } } diff --git a/src/components/CardLayout.cpp b/src/components/CardLayout.cpp new file mode 100644 index 00000000..0f3a2fc8 --- /dev/null +++ b/src/components/CardLayout.cpp @@ -0,0 +1,69 @@ +#include "CardLayout.h" + +#include + +#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& 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, 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_; +} diff --git a/src/components/CardLayout.h b/src/components/CardLayout.h new file mode 100644 index 00000000..23d0d505 --- /dev/null +++ b/src/components/CardLayout.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +#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, 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& 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_; +};