feat(koreader): add dashboard reading statistics and recency metadata

Add lastReadAt timestamp to KOReader catalog book items and introduce reading streak and goal statistics widgets to the KOReader plugin dashboard layout.
This commit is contained in:
neon
2026-07-30 23:34:25 +00:00
parent 50ba5f151b
commit 40e366a463
27 changed files with 1136 additions and 161 deletions
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M16 14V8h24v24h-6" fill="none" stroke="#000000" stroke-width="4" stroke-linejoin="miter"/>
<rect x="8" y="16" width="24" height="24" rx="3" fill="#FFFFFF" stroke="#000000" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="9" y="6" width="30" height="36" rx="3" fill="none" stroke="#000000" stroke-width="4"/>
<path d="M19 6v19l5-5 5 5V6" fill="#FFFFFF" stroke="#000000" stroke-width="4" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="13" y="5" width="22" height="38" rx="4" fill="none" stroke="#000000" stroke-width="4"/>
<path d="M20 36h8" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M6 15h9l16 18h9" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M6 33h9l16-18h9" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M34 9l6 6-6 6" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M34 27l6 6-6 6" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="#000000" stroke-width="4"/>
<circle cx="24" cy="24" r="8" fill="none" stroke="#000000" stroke-width="4"/>
<circle cx="24" cy="24" r="3" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M28 3c1 12 12 17 12 26a16 16 0 0 1-32 0c0-7 4-11 7-17 2 7 6 10 8 11 3-8 4-13 5-20z" fill="none" stroke="#000000" stroke-width="4" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="#000000" stroke-width="4"/>
<path d="M24 13v11h9" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M13 38V26" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
<path d="M24 38V10" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
<path d="M35 38V19" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
<path d="M8 42h32" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

@@ -558,6 +558,13 @@ function BookOrbitApi:catalogDiscover()
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
end
-- One server-composed dashboard section (want-to-read, up-next-in-series).
-- Only valid against servers advertising the catalogDashboardSections
-- capability; older ones answer 404.
function BookOrbitApi:catalogDashboardSection(section_type)
return self:request("GET", "/koreader/plugin/catalog/dashboard/sections/" .. util.urlEncode(tostring(section_type)))
end
function BookOrbitApi:catalogSection(section, params)
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
end
@@ -234,6 +234,16 @@ function BookOrbitCatalog:init()
self.client = BookOrbitApi.new(self.api)
self.stack = {}
self.settings = self.settings or {}
CatalogWidgets.setAssetIconFiles({
on_device = self:pluginAssetFile("bookorbit.on-device.svg"),
shuffle = self:pluginAssetFile("bookorbit.shuffle.svg"),
in_progress = self:pluginAssetFile("bookorbit.in-progress.svg"),
collections = self:pluginAssetFile("bookorbit.collections.svg"),
stat_today = self:pluginAssetFile("bookorbit.stat-today.svg"),
stat_week = self:pluginAssetFile("bookorbit.stat-week.svg"),
stat_streak = self:pluginAssetFile("bookorbit.stat-streak.svg"),
stat_goal = self:pluginAssetFile("bookorbit.stat-goal.svg"),
})
self:initThumbnailCache()
self:initBulkDownloadState()
self.view_mode = self.settings.catalog_view_mode == "list" and "list" or "mosaic"
@@ -276,12 +286,15 @@ function BookOrbitCatalog:init()
end)
end
function BookOrbitCatalog:downloadIconPath()
function BookOrbitCatalog:pluginAssetFile(name)
local source_dir = self.path or (self._manager and self._manager.path)
if source_dir then
local path = source_dir .. "/assets/" .. DOWNLOAD_ICON_FILE
if lfs.attributes(path, "mode") == "file" then return path end
end
if not source_dir then return nil end
local path = source_dir .. "/assets/" .. name
if lfs.attributes(path, "mode") == "file" then return path end
end
function BookOrbitCatalog:downloadIconPath()
return self:pluginAssetFile(DOWNLOAD_ICON_FILE)
end
function BookOrbitCatalog:isBulkSelectionActive()
@@ -561,6 +574,16 @@ function BookOrbitCatalog:isOnDevice(book)
return book ~= nil and book.id ~= nil and self.on_device[book.id] ~= nil
end
-- The local file a Continue-reading tap should resume: only when the
-- tap-to-resume setting is on and the book is already on the device. Nil sends
-- the tap to the detail page instead.
function BookOrbitCatalog:dashboardResumePath(book)
if self.settings.catalog_dashboard_tap_resumes == false then return nil end
local path = book ~= nil and book.id ~= nil and self.on_device[book.id] or nil
if type(path) == "string" and path ~= "" then return path end
return nil
end
function BookOrbitCatalog:onDeviceFilePath(file)
return file ~= nil and file.id ~= nil and self.on_device_files and self.on_device_files[file.id] or nil
end
@@ -1890,7 +1913,15 @@ end
function BookOrbitCatalog:finishCustomUpdate(old_dimen, select_number)
self:updatePageInfo(select_number)
Menu.mergeTitleBarIntoLayout(self)
-- A view may narrow the e-ink refresh to the region it actually changed
-- (the dashboard does for row page turns); everything else refreshes the
-- whole menu as before.
local refresh_region = self.custom_refresh_region
self.custom_refresh_region = nil
UIManager:setDirty(self.show_parent, function()
if refresh_region then
return "ui", refresh_region
end
local refresh_dimen = old_dimen and old_dimen:combine(self.dimen) or self.dimen
return "ui", refresh_dimen
end)
@@ -2161,7 +2192,14 @@ function BookOrbitCatalog:onMenuSelect(item)
if item.kind == "section" then
self:loadSection(item.section)
elseif item.kind == "dashboard-book" then
self:loadBookDetail(item.book_id)
-- Continue-reading heroes resume the book directly when it is on the
-- device; hold still opens the full action sheet.
local resume_path = item.resume and self:dashboardResumePath(item.book) or nil
if resume_path then
self:openDownloadedFile(resume_path)
else
self:loadBookDetail(item.book_id)
end
elseif item.kind == "dashboard-highlight" then
self:loadBookDetail(item.book_id)
elseif item.kind == "dashboard-search" then
@@ -27,12 +27,14 @@ local NetworkMgr = require("ui/network/manager")
local Screen = require("device").screen
local Size = require("ui/size")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local T = require("ffi/util").template
local _ = require("gettext")
local Capabilities = require("bookorbit_capabilities")
local CatalogUtil = require("bookorbit_catalog_util")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local DashboardSections = require("bookorbit_dashboard_sections")
@@ -40,10 +42,12 @@ local BookOrbitStatsReader = require("bookorbit_stats_reader")
local formatDuration = CatalogUtil.formatDuration
local formatProgress = CatalogUtil.formatProgress
local formatRelativeTime = CatalogUtil.formatRelativeTime
local readingStreakDays = CatalogUtil.readingStreakDays
local BookOrbitDashboardCoverCard = CatalogWidgets.DashboardCoverCard
local BookOrbitDashboardHeroCard = CatalogWidgets.DashboardHeroCard
local BookOrbitDashboardHighlightCard = CatalogWidgets.DashboardHighlightCard
local BookOrbitDashboardBrowseRow = CatalogWidgets.DashboardBrowseRow
local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
@@ -59,6 +63,20 @@ local SECTION_PAGE_PREFIX = "section"
local SHELF_PAGE_SIZE = 12
-- Cover cards never get narrower than this, however tight the page is.
local SHELF_MIN_CARD_WIDTH = 72
-- Header controls are drawn inside the section header's label row, so the row
-- stays as tall as a header without controls; the tap box reaches into the gap
-- above and below it to stay finger-sized.
local HEADER_CONTROL_ICON = 19
local HEADER_CONTROL_TAP_PAD = 9
-- The seven-day activity chart inside the stats strip.
local WEEK_BAR_MAX_HEIGHT = 18
local WEEK_BAR_WIDTH = 5
local WEEK_BAR_GAP = 3
-- Breathing room above and below the stats blocks, inside the strip's rules.
local STATS_BODY_PADDING = 6
-- The marker a stats block shows between its value and its label when it has
-- no sparkline of its own.
local STATS_ICON_SIZE = 20
local CatalogDashboard = {}
@@ -105,13 +123,23 @@ function CatalogDashboard.dashboardItems()
}
end
local function greetingForHour(hour)
if hour >= 5 and hour < 12 then
return _("Good morning"), _("Good morning, %1")
elseif hour >= 12 and hour < 18 then
return _("Good afternoon"), _("Good afternoon, %1")
end
return _("Good evening"), _("Good evening, %1")
end
function CatalogDashboard:dashboardSubtitle(dashboard)
local username = dashboard and (dashboard.displayName or dashboard.name or dashboard.username)
or (self.settings and self.settings.username)
local plain, templated = greetingForHour(tonumber(os.date("%H")) or 12)
if type(username) == "string" and username ~= "" then
return T(_("Hi, %1"), username)
return T(templated, username)
end
return _("Hi")
return plain
end
function CatalogDashboard:dashboardContext(dashboard, opts)
@@ -193,6 +221,28 @@ function CatalogDashboard:dashboardRoot(opts)
end
return { type = config.type, books = response.discover or {} }
end
-- Sources without a catalog-books equivalent go through the server's
-- dashboard-section endpoint. A confirmed 404/405 downgrades the
-- capability so the picker stops offering them against this server.
if DashboardSections.usesSectionEndpoint(config.type) then
local known = Capabilities.cached(self.client)
if known and not known[DashboardSections.SECTION_ENDPOINT_CAPABILITY] then
return { type = config.type, books = {} }
end
local response, section_err = self:fetch(_("Loading dashboard..."), function()
return self.client:catalogDashboardSection(config.type)
end, opts)
if isDashboardUnsupported(section_err) then
Capabilities.markUnsupported(self.client, DashboardSections.SECTION_ENDPOINT_CAPABILITY)
return { type = config.type, books = {} }
end
if section_err or type(response) ~= "table" then
return nil, section_err or "invalid response"
end
local section = type(response.section) == "table" and response.section or {}
return { type = config.type, books = section.books or {} }
end
local params = { page = 1, size = SHELF_PAGE_SIZE }
if config.type == "recently-added" then
params.sort = "recently_added"
@@ -293,11 +343,12 @@ function CatalogDashboard.dashboardBooks(dashboard)
return books
end
function CatalogDashboard:dashboardActionEntries()
function CatalogDashboard:dashboardActionEntries(total_books)
return {
{
text = _("In progress"),
icon = "dogear.reading",
icon_file = CatalogWidgets.assetIconFile("in_progress"),
kind = "books",
params = { sort = "recently_read", readStatus = "reading" },
},
@@ -316,6 +367,7 @@ function CatalogDashboard:dashboardActionEntries()
{
text = _("All Books"),
icon = "appbar.pageview",
mandatory = total_books and tostring(total_books) or nil,
kind = "books",
params = { sort = "title" },
},
@@ -334,6 +386,7 @@ function CatalogDashboard:dashboardActionEntries()
{
text = _("Collections"),
icon = "texture-box",
icon_file = CatalogWidgets.assetIconFile("collections"),
kind = "section",
section = "collections",
},
@@ -343,6 +396,11 @@ function CatalogDashboard:dashboardActionEntries()
kind = "section",
section = "smart-scopes",
},
{
text = _("Search"),
icon = "appbar.search",
kind = "dashboard-search",
},
}
end
@@ -371,20 +429,25 @@ end
-- thick underline running into a hairline rule. Controls (paging chevrons,
-- the Discover reroll) are pinned to the header's right edge so the rows
-- below keep the full content width and stay aligned with the margins.
function CatalogDashboard:addDashboardHeader(text, controls)
-- focus_controls names the tappable subset when controls also carries
-- display-only widgets such as the page indicator.
function CatalogDashboard:addDashboardHeader(text, controls, focus_controls)
local right
if controls and #controls > 0 then
right = HorizontalGroup:new{ align = "center" }
for index, control in ipairs(controls) do
if index > 1 then
table.insert(right, HorizontalSpan:new{ width = Size.span.horizontal_default })
-- The control boxes already carry padding around their glyphs;
-- a wide span on top of that scatters the cluster.
table.insert(right, HorizontalSpan:new{ width = Size.span.horizontal_small })
end
table.insert(right, control)
end
end
self:addDashboardInset(CatalogWidgets.buildDashboardSectionHeader(text, self.content_w, right))
if controls and #controls > 0 then
table.insert(self.layout, controls)
local focus_row = focus_controls or controls
if focus_row and #focus_row > 0 then
table.insert(self.layout, focus_row)
end
end
@@ -394,34 +457,51 @@ function CatalogDashboard:dashboardSectionSupportsReroll(config)
return (config or {}).type == DashboardSections.DEFAULT_TYPE
end
function CatalogDashboard:buildDashboardRerollButton(index)
local size = Screen:scaleBySize(24)
-- Every header control is a square the size of the header's label row, so the
-- controls fill that row rather than making the header taller than one without
-- them.
function CatalogDashboard:buildDashboardHeaderControl(opts)
local size = CatalogWidgets.dashboardSectionHeaderRowHeight()
return BookOrbitDashboardIconButton:new{
entry = { kind = "dashboard-reroll", section_index = index or 1, icon = "cre.render.reload" },
entry = opts.entry,
dimen = Geom:new{ x = 0, y = 0, w = size, h = size },
icon_size = Screen:scaleBySize(HEADER_CONTROL_ICON),
tap_padding_v = Screen:scaleBySize(HEADER_CONTROL_TAP_PAD),
enabled = opts.enabled,
callback = opts.callback,
menu = self,
}
end
-- A pair of small borderless paging chevrons for a section header.
function CatalogDashboard:buildDashboardRerollButton(index)
return self:buildDashboardHeaderControl{
entry = {
kind = "dashboard-reroll",
section_index = index or 1,
icon = "cre.render.reload",
icon_file = CatalogWidgets.assetIconFile("shuffle"),
},
}
end
-- Paging chevrons for a section header, with a "page/pages" indicator between
-- them so a shelf's extent is visible.
function CatalogDashboard:buildDashboardHeaderNav(section_id, page, page_count)
local size = Screen:scaleBySize(24)
local function navButton(icon, enabled, delta)
return Button:new{
icon = icon,
icon_width = Screen:scaleBySize(12),
icon_height = Screen:scaleBySize(12),
width = size,
height = size,
bordersize = 0,
show_border = false,
return self:buildDashboardHeaderControl{
entry = { icon = icon },
enabled = enabled,
callback = function()
self:turnDashboardPage(section_id, delta)
end,
}
end
return navButton("chevron.left", page > 1, -1), navButton("chevron.right", page < page_count, 1)
local indicator = TextWidget:new{
text = tostring(page) .. "/" .. tostring(page_count),
face = Font:getFace("cfont", 12),
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
}
return navButton("chevron.left", page > 1, -1), navButton("chevron.right", page < page_count, 1), indicator
end
function CatalogDashboard:dashboardPage(section_id, page_count)
@@ -437,6 +517,9 @@ function CatalogDashboard:turnDashboardPage(section_id, delta)
local context = self.current_context or {}
context.dash_pages = context.dash_pages or {}
context.dash_pages[section_id] = math.max(1, (tonumber(context.dash_pages[section_id]) or 1) + delta)
-- A page turn changes one section band and nothing else, so only that band
-- needs an e-ink refresh; the rebuild below records the band's position.
self.dash_refresh_section = section_id
self:updateItems(nil, true)
end
@@ -449,6 +532,10 @@ function CatalogDashboard:dashboardHeroMetaText(book)
local status = self:readStatusLabel(book)
if status then table.insert(parts, status) end
end
local recency = formatRelativeTime(book.lastReadAt)
if recency then
table.insert(parts, recency)
end
if self:isOnDevice(book) then
table.insert(parts, _("On device"))
end
@@ -461,6 +548,7 @@ function CatalogDashboard:buildDashboardHeroCard(book, width, height)
kind = "dashboard-book",
book_id = book.id,
book = book,
resume = true,
meta_text = self:dashboardHeroMetaText(book),
},
dimen = Geom:new{ x = 0, y = 0, w = width, h = height },
@@ -620,6 +708,21 @@ function CatalogDashboard:addDashboardEmptyState(text, button_text, callback)
table.insert(self.layout, { button })
end
-- A muted status line shown while the dashboard renders cached data, built
-- from the generatedAt timestamp the cached body carries.
function CatalogDashboard:buildDashboardStatusLine(dashboard)
local updated = formatRelativeTime(dashboard and dashboard.generatedAt)
local text
if NetworkMgr:isConnected() then
text = updated and T(_("Showing cached dashboard - updated %1"), updated)
or _("Showing cached dashboard")
else
text = updated and T(_("Offline - updated %1"), updated)
or _("Offline - showing cached dashboard")
end
return CatalogWidgets.buildStatusLabel(text, self.content_w, nil, "center")
end
-- Local reading activity from statistics.sqlite3, cached briefly so row page
-- turns do not reopen the database. The local streak is retained as a fallback
-- for cached responses from servers that do not provide the account streak.
@@ -638,37 +741,117 @@ function CatalogDashboard:dashboardStatsSummary()
return summary
end
-- Three plain stat blocks separated by hairlines: no boxes, so the strip
-- reads as information rather than competing with the tappable cards.
-- The last seven days as bar heights, oldest first, normalized to the busiest
-- day. Falls back to the account streak's read/not-read days when local
-- statistics carry nothing, and to nil when neither side has data.
local function weekBarData(summary, dashboard)
local day_seconds = type(summary.day_seconds) == "table" and summary.day_seconds or {}
local values, max_value = {}, 0
for day = 1, 7 do
local value = tonumber(day_seconds[day]) or 0
values[day] = value
max_value = math.max(max_value, value)
end
if max_value > 0 then return values, max_value end
local reading_streak = dashboard and dashboard.readingStreak
local last_seven = type(reading_streak) == "table" and reading_streak.lastSevenDays or nil
if type(last_seven) ~= "table" then return nil end
local any = false
for day = 1, 7 do
values[day] = last_seven[day] and 1 or 0
any = any or values[day] > 0
end
if not any then return nil end
return values, 1
end
-- A seven-day activity chart under the "Past 7 days" value: a bar per day
-- scaled against the busiest one and standing on a shared baseline, today
-- solid black and earlier days dark gray.
function CatalogDashboard:buildDashboardWeekBars(summary, dashboard)
local values, max_value = weekBarData(summary, dashboard)
if not values then return nil end
local max_h = Screen:scaleBySize(WEEK_BAR_MAX_HEIGHT)
local min_fill = math.max(1, Screen:scaleBySize(2))
local bar_w = Screen:scaleBySize(WEEK_BAR_WIDTH)
local gap = Screen:scaleBySize(WEEK_BAR_GAP)
local row = HorizontalGroup:new{ align = "bottom" }
for day = 1, 7 do
if day > 1 then
table.insert(row, HorizontalSpan:new{ width = gap })
end
local fill_h = values[day] > 0
and math.max(min_fill, math.floor(max_h * values[day] / max_value + 0.5))
or 0
table.insert(row, CatalogWidgets.buildDashboardWeekBar(bar_w, max_h, fill_h, day == 7))
end
return CatalogWidgets.buildDashboardWeekChart(row)
end
-- Activity blocks separated by hairlines: no boxes, so the strip reads as
-- information rather than competing with the tappable cards. Inventory counts
-- live in the Browse block; the strip keeps to reading activity plus the
-- yearly goal when one is set.
function CatalogDashboard:buildDashboardStatsStrip(summary, dashboard, height)
local sep_w = Size.line.thin
local today_seconds = summary.today_seconds or 0
local streak_days = readingStreakDays(dashboard, summary.streak_days)
local week_bars = self:buildDashboardWeekBars(summary, dashboard)
-- The blocks without a sparkline take an icon in that row instead, so the
-- strip reads as four equally furnished blocks rather than one chart and
-- three gaps.
local icon_size = Screen:scaleBySize(STATS_ICON_SIZE)
local blocks = {
{ value = today_seconds > 0 and formatDuration(today_seconds) or _("Not yet"), label = _("Today") },
{ value = formatDuration(summary.week_seconds or 0), label = _("Past 7 days") },
{ value = tostring(streak_days), label = _("Day streak") },
{
value = formatDuration(summary.today_seconds or 0),
label = _("Today"),
extra = CatalogWidgets.buildDashboardStatIcon("stat_today", icon_size),
},
{
value = formatDuration(summary.week_seconds or 0),
label = _("Past 7 days"),
-- No per-day data to chart (a fresh install, or a server without
-- the streak) still gets a marker rather than the lone gap.
extra = week_bars or CatalogWidgets.buildDashboardStatIcon("stat_week", icon_size),
},
{
value = tostring(streak_days),
label = _("Day streak"),
extra = CatalogWidgets.buildDashboardStatIcon("stat_streak", icon_size),
},
}
local total = dashboard and tonumber(dashboard.totalBooks or dashboard.bookCount)
if total then
table.insert(blocks, { value = tostring(total), label = _("Library") })
local reading_goal = dashboard and type(dashboard.readingGoal) == "table" and dashboard.readingGoal or nil
local goal_books = reading_goal and tonumber(reading_goal.goalBooks) or nil
if goal_books and goal_books > 0 then
table.insert(blocks, {
value = tostring(tonumber(reading_goal.completedBooks) or 0) .. " / " .. tostring(goal_books),
label = reading_goal.year and T(_("%1 goal"), reading_goal.year) or _("Yearly goal"),
extra = CatalogWidgets.buildDashboardStatIcon("stat_goal", icon_size),
})
end
table.insert(blocks, { value = tostring(self:onDeviceCount()), label = _("On device") })
-- One set of row heights for every block, so the values sit on one baseline
-- and the labels on another whether a block carries the sparkline or an icon.
local marker_h = week_bars and week_bars:getSize().h or 0
for _, block in ipairs(blocks) do
if block.extra then marker_h = math.max(marker_h, block.extra:getSize().h) end
end
local metrics = CatalogWidgets.dashboardStatMetrics(marker_h)
local block_w = math.floor((self.content_w - (#blocks - 1) * sep_w) / #blocks)
local row = HorizontalGroup:new{ align = "center" }
for index, block in ipairs(blocks) do
if index > 1 then
table.insert(row, LineWidget:new{
background = Blitbuffer.COLOR_LIGHT_GRAY,
dimen = Geom:new{ w = sep_w, h = Screen:scaleBySize(18) },
dimen = Geom:new{ w = sep_w, h = metrics.total_h },
})
end
table.insert(row, CatalogWidgets.buildDashboardStat(block.value, block.label, block_w))
table.insert(row, CatalogWidgets.buildDashboardStat(
block.value, block.label, block_w, block.extra, metrics))
end
local line_h = Size.line.thin
local body_h = height and math.max(row:getSize().h, height - 2 * line_h)
or math.max(row:getSize().h, Screen:scaleBySize(STATS_MIN_BODY_HEIGHT))
local padded_h = row:getSize().h + 2 * Screen:scaleBySize(STATS_BODY_PADDING)
local body_h = height and math.max(padded_h, height - 2 * line_h)
or math.max(padded_h, Screen:scaleBySize(STATS_MIN_BODY_HEIGHT))
return VerticalGroup:new{
align = "center",
LineWidget:new{
@@ -743,18 +926,20 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local context = self.current_context or {}
local dashboard = context.dashboard or {}
local continue_books = dashboard.continueReading or {}
local action_entries = self:dashboardActionEntries()
local action_entries = self:dashboardActionEntries(tonumber(dashboard.totalBooks or dashboard.bookCount))
local summary = self:dashboardStatsSummary() or { today_seconds = 0, week_seconds = 0, streak_days = 0 }
local stale_sections = type(context.section_stale) == "table" and context.section_stale or {}
local highlight = type(dashboard.highlightOfTheDay) == "table" and dashboard.highlightOfTheDay or nil
local function px(n) return Screen:scaleBySize(n) end
local avail = self.available_height
local inner_gap = px(9)
local top_gap = px(4)
local section_gap = px(14)
local section_gap = px(18)
local compact_gap = px(SECTION_COMPACT_GAP)
self.dash_inner_gap = inner_gap
self.dash_used = 0
self.dash_section_bands = {}
local header_h = CatalogWidgets.buildDashboardSectionHeader("X", self.content_w):getSize().h
local browse_row_h = px(42)
@@ -762,21 +947,28 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local browse_rows = 3
local hero_h = math.min(px(150), math.max(px(76), math.floor(avail * 0.18)))
local status_widget
if context.stale and not context.loading and context.dashboard then
status_widget = self:buildDashboardStatusLine(dashboard)
end
local status_h = status_widget and (status_widget:getSize().h + compact_gap) or 0
local configs = {}
local stats_widgets = {}
local highlight_heights = {}
local hidden = {}
for index = 1, DashboardSections.SLOT_COUNT do
configs[index] = DashboardSections.at(self.settings, index)
end
local function isShelf(config)
return config.type ~= "stats" and config.type ~= "continue-reading" and config.type ~= "browse"
return DashboardSections.isBookSource(config.type)
end
-- Slots with a native renderer take the height they need; the shelves share
-- whatever is left of the page.
local function measureFixed()
local fixed = top_gap
local fixed = top_gap + status_h
local shelf_count = 0
local max_shelf_books = 0
for index = 1, DashboardSections.SLOT_COUNT do
@@ -790,6 +982,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
fixed = fixed + header_h + inner_gap + hero_h + section_gap
elseif config.type == "browse" then
fixed = fixed + header_h + inner_gap + browse_rows * browse_row_h + section_gap
elseif config.type == "highlight" then
highlight_heights[index] = highlight_heights[index]
or (highlight and CatalogWidgets.dashboardHighlightCardHeight(highlight, self.content_w) or px(48))
fixed = fixed + header_h + inner_gap + highlight_heights[index] + section_gap
else
shelf_count = shelf_count + 1
max_shelf_books = math.max(max_shelf_books, #self.dashboardSlotBooks(dashboard, index))
@@ -815,11 +1011,15 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
-- Too many slots for the page: the stats strip goes first, since it only
-- summarises what the reader can reach elsewhere, then shelves bottom-up.
-- summarises what the reader can reach elsewhere, then the highlight card,
-- then shelves bottom-up.
local function slotToDrop()
for index = 1, DashboardSections.SLOT_COUNT do
if not hidden[index] and configs[index].type == "stats" then return index end
end
for index = 1, DashboardSections.SLOT_COUNT do
if not hidden[index] and configs[index].type == "highlight" then return index end
end
for index = DashboardSections.SLOT_COUNT, 1, -1 do
if not hidden[index] and isShelf(configs[index]) then return index end
end
@@ -833,6 +1033,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
self:addDashboardSpacer(top_gap)
if status_widget then
self:addDashboardInset(status_widget)
self:addDashboardSpacer(compact_gap)
end
local function renderStats(index)
self:addDashboardInset(stats_widgets[index])
end
@@ -842,12 +1046,14 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local hero_pages = #continue_books > 0 and math.max(1, math.ceil(#continue_books / hero_slots)) or 0
local page_id = "continue" .. tostring(index)
local hero_page = hero_pages > 0 and self:dashboardPage(page_id, hero_pages) or 1
local controls
local band_start = self.dash_used
local controls, focus_controls
if hero_pages > 1 then
local prev_button, next_button = self:buildDashboardHeaderNav(page_id, hero_page, hero_pages)
controls = { prev_button, next_button }
local prev_button, next_button, indicator = self:buildDashboardHeaderNav(page_id, hero_page, hero_pages)
controls = { prev_button, indicator, next_button }
focus_controls = { prev_button, next_button }
end
self:addDashboardHeader(DashboardSections.headerText(config), controls)
self:addDashboardHeader(DashboardSections.headerText(config), controls, focus_controls)
self:addDashboardSpacer(inner_gap)
if #continue_books > 0 then
self:addDashboardHeroRow(continue_books, hero_h, hero_slots, hero_page)
@@ -860,6 +1066,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self:onMenuSelect({ text = _("All Books"), kind = "books", params = { sort = "title" } })
end)
end
self.dash_section_bands[page_id] = { y = band_start, h = self.dash_used - band_start }
end
local function renderBrowse(config)
@@ -868,6 +1075,24 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self:addDashboardBrowseList(action_entries, browse_row_h, browse_cols, browse_rows)
end
local function renderHighlight(index, config)
self:addDashboardHeader(DashboardSections.headerText(config))
self:addDashboardSpacer(inner_gap)
if highlight then
local card_h = highlight_heights[index]
or CatalogWidgets.dashboardHighlightCardHeight(highlight, self.content_w)
self:addDashboardInset(BookOrbitDashboardHighlightCard:new{
entry = { kind = "dashboard-highlight", book_id = highlight.bookId, highlight = highlight },
dimen = Geom:new{ x = 0, y = 0, w = self.content_w, h = card_h },
menu = self,
}, highlight.bookId ~= nil)
elseif context.loading then
self:addDashboardEmptyState(_("Loading..."))
else
self:addDashboardEmptyState(_("No highlights yet."))
end
end
local function renderShelf(index, config)
local page_id = SECTION_PAGE_PREFIX .. tostring(index)
local pending = stale_sections[index] == true
@@ -875,16 +1100,23 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local slots = math.max(1, math.min(shelf_slots, #books))
local pages = math.max(1, math.ceil(#books / slots))
local page = self:dashboardPage(page_id, pages)
local band_start = self.dash_used
local controls = {}
local focus_controls = {}
if pages > 1 then
local prev_button, next_button = self:buildDashboardHeaderNav(page_id, page, pages)
local prev_button, next_button, indicator = self:buildDashboardHeaderNav(page_id, page, pages)
table.insert(controls, prev_button)
table.insert(controls, indicator)
table.insert(controls, next_button)
table.insert(focus_controls, prev_button)
table.insert(focus_controls, next_button)
end
if not pending and self:dashboardSectionSupportsReroll(config) then
table.insert(controls, self:buildDashboardRerollButton(index))
local reroll_button = self:buildDashboardRerollButton(index)
table.insert(controls, reroll_button)
table.insert(focus_controls, reroll_button)
end
self:addDashboardHeader(DashboardSections.headerText(config), controls)
self:addDashboardHeader(DashboardSections.headerText(config), controls, focus_controls)
self:addDashboardSpacer(inner_gap)
if pending or context.loading then
self:addDashboardEmptyState(_("Loading..."))
@@ -893,6 +1125,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
else
self:addDashboardCoverGrid(page_id, books, shelf_row_h, false, false, slots, shelf_card_w, page, 1)
end
self.dash_section_bands[page_id] = { y = band_start, h = self.dash_used - band_start }
end
for index = 1, DashboardSections.SLOT_COUNT do
@@ -904,6 +1137,8 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
renderContinueReading(index, config)
elseif config.type == "browse" then
renderBrowse(config)
elseif config.type == "highlight" then
renderHighlight(index, config)
else
renderShelf(index, config)
end
@@ -912,6 +1147,24 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
self:addDashboardSpacer(math.max(0, avail - self.dash_used))
-- A page turn recorded which band changed; hand its rect to the refresh so
-- the rest of the page keeps its e-ink state. Anything unexpected falls
-- back to the full-region refresh.
local refresh_section = self.dash_refresh_section
self.dash_refresh_section = nil
local band = refresh_section and self.dash_section_bands[refresh_section] or nil
if band and band.h > 0 and self.dimen and self.menuChromeHeight then
local top_height = self:menuChromeHeight()
local pad = px(4)
local band_y = self.dimen.y + top_height + math.max(0, band.y - pad)
self.custom_refresh_region = Geom:new{
x = self.dimen.x,
y = band_y,
w = self.dimen.w,
h = band.h + 2 * pad,
}
end
self:finishCustomUpdate(old_dimen, select_number)
end
@@ -138,6 +138,55 @@ function CatalogUtil.formatProgress(value)
return tostring(math.floor(value + 0.5)) .. "%"
end
-- Parses an ISO-8601 UTC timestamp ("2026-07-30T12:34:56.000Z") to an epoch,
-- or nil for anything unparseable. os.time reads the fields as local time, so
-- the current UTC offset is added back.
local function parseIsoTimestamp(value)
if type(value) ~= "string" then return nil end
local year, month, day, hour, min, sec = value:match("^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
if not year then return nil end
local as_local = os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = tonumber(hour),
min = tonumber(min),
sec = tonumber(sec),
})
if not as_local then return nil end
-- The probe table must infer DST exactly like the parsed table above did
-- (os.date("!*t") pins isdst=false, which skews zones currently in DST by
-- an hour), so both conversions share the same offset.
local probe = os.date("!*t")
probe.isdst = nil
local offset = os.time() - os.time(probe)
return as_local + offset
end
-- A coarse relative label for a UTC timestamp: "just now", "12 min ago",
-- "3 h ago", "yesterday", "5 days ago". Nil when the value cannot be parsed.
function CatalogUtil.formatRelativeTime(value, now)
local timestamp = parseIsoTimestamp(value)
if not timestamp then return nil end
now = now or os.time()
local delta = now - timestamp
if delta < 0 then delta = 0 end
if delta < 60 then
return _("just now")
end
if delta < 3600 then
return T(_("%1 min ago"), math.floor(delta / 60))
end
if delta < 86400 then
return T(_("%1 h ago"), math.floor(delta / 3600))
end
local days = math.floor(delta / 86400)
if days == 1 then
return _("yesterday")
end
return T(_("%1 days ago"), days)
end
function CatalogUtil.readingStreakDays(dashboard, fallback)
local reading_streak = dashboard and dashboard.readingStreak
local current_streak = reading_streak and tonumber(reading_streak.currentStreak)
@@ -54,6 +54,19 @@ local READ_STATUS_BADGE_ICONS = {
local CatalogWidgets = {}
-- Absolute paths to the plugin's own SVG icons, resolved once by the catalog
-- (widgets cannot know the plugin directory). A missing entry falls back to
-- the stock KOReader glyph named alongside it.
local asset_icon_files = {}
function CatalogWidgets.setAssetIconFiles(files)
asset_icon_files = type(files) == "table" and files or {}
end
function CatalogWidgets.assetIconFile(name)
return asset_icon_files[name]
end
local function hasProgress(book)
return book and book.progressPercentage and book.progressPercentage > 0
end
@@ -110,16 +123,17 @@ function CatalogWidgets.buildProgressBar(percentage, width)
return bar
end
function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
local inner_w = math.max(1, width - 2 * Size.padding.default - 2 * Size.border.thin)
local inner_h = math.max(1, height - 2 * Size.padding.default - 2 * Size.border.thin)
function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, bordersize)
bordersize = bordersize or Size.border.thin
local inner_w = math.max(1, width - 2 * Size.padding.default - 2 * bordersize)
local inner_h = math.max(1, height - 2 * Size.padding.default - 2 * bordersize)
if quiet then
return FrameContainer:new{
width = width,
height = height,
margin = 0,
padding = Size.padding.default,
bordersize = Size.border.thin,
bordersize = bordersize,
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
@@ -170,7 +184,7 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
height = height,
margin = 0,
padding = Size.padding.default,
bordersize = Size.border.thin,
bordersize = bordersize,
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
@@ -179,18 +193,29 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
}
end
function CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet_placeholder)
-- opts.no_border drops the cover's own outline for callers that already draw
-- one around it (the dashboard cards), so a card is not ringed twice.
-- opts.quiet_placeholder swaps the titled fake cover for a bare one while a
-- cover is still downloading.
function CatalogWidgets.buildCoverWidget(book, width, height, path, state, opts)
opts = opts or {}
local bordersize = opts.no_border and 0 or Size.border.thin
if path then
return CenterContainer:new{
dimen = Geom:new{ w = width, h = height },
FrameContainer:new{
margin = 0,
padding = 0,
bordersize = Size.border.thin,
local inner_w = math.max(1, width - 2 * bordersize)
local inner_h = math.max(1, height - 2 * bordersize)
return FrameContainer:new{
width = width,
height = height,
margin = 0,
padding = 0,
bordersize = bordersize,
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
ImageWidget:new{
file = path,
width = width,
height = height,
width = inner_w,
height = inner_h,
scale_factor = 0,
},
},
@@ -205,38 +230,61 @@ function CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet
else
footer = _("No cover")
end
return CatalogWidgets.buildFakeCover(book, width, height, footer, quiet_placeholder == true)
-- Quiet placeholders are reserved for covers still loading; a cover that is
-- missing or failed keeps the titled fake cover so the book stays
-- identifiable on caption-less shelves.
return CatalogWidgets.buildFakeCover(
book, width, height, footer, opts.quiet_placeholder == true and state == "loading", bordersize)
end
-- Badges sit on a small white plate so they stay legible over busy cover art;
-- a bare glyph disappears against dark covers on e-ink.
local function badgeChip(icon_widget)
return FrameContainer:new{
margin = 0,
padding = Size.padding.tiny,
bordersize = Size.border.thin,
radius = Size.radius.default,
background = Blitbuffer.COLOR_WHITE,
icon_widget,
}
end
function CatalogWidgets.buildReadStatusBadge(book, max_width)
local icon = readStatusBadgeIcon(book)
if not icon then return nil end
local size = math.max(Screen:scaleBySize(12), math.min(max_width, Screen:scaleBySize(20)))
return IconWidget:new{
local size = math.max(Screen:scaleBySize(10), math.min(max_width, Screen:scaleBySize(16)))
return badgeChip(IconWidget:new{
icon = icon,
rotation_angle = readStatusBadgeRotation(icon),
width = size,
height = size,
}
})
end
function CatalogWidgets.buildSelectionBadge(max_width)
local size = math.max(Screen:scaleBySize(16), math.min(max_width, Screen:scaleBySize(26)))
return IconWidget:new{
local size = math.max(Screen:scaleBySize(13), math.min(max_width, Screen:scaleBySize(22)))
return badgeChip(IconWidget:new{
icon = "check",
width = size,
height = size,
}
})
end
-- IconWidget resolves either a stock glyph name or an absolute file path;
-- never hand it both.
local function iconOrFile(icon, file)
if file then return { file = file } end
return { icon = icon }
end
function CatalogWidgets.buildDownloadedBadge(max_width)
local size = math.max(Screen:scaleBySize(14), math.min(max_width, Screen:scaleBySize(24)))
return IconWidget:new{
icon = "appbar.filebrowser",
width = size,
height = size,
}
local size = math.max(Screen:scaleBySize(11), math.min(max_width, Screen:scaleBySize(18)))
local opts = iconOrFile("appbar.filebrowser", CatalogWidgets.assetIconFile("on_device"))
opts.width = size
opts.height = size
return badgeChip(IconWidget:new(opts))
end
function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected)
@@ -637,9 +685,15 @@ function CatalogWidgets.buildDetailProgressBar(percentage, width, height)
}
end
function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state, downloaded, quiet_placeholder)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet_placeholder)
local read_badge = CatalogWidgets.buildReadStatusBadge(book, math.floor(math.min(width, height) * 0.18))
-- Badges sit over the cover art, so they are kept to a smaller share of the
-- card than the catalog grid's: a dashboard shelf shows several covers at once
-- and a chunky chip on each one buries the artwork.
local DASHBOARD_BADGE_RATIO = 0.15
function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state, downloaded, cover_opts)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, cover_opts)
local badge_w = math.floor(math.min(width, height) * DASHBOARD_BADGE_RATIO)
local read_badge = CatalogWidgets.buildReadStatusBadge(book, badge_w)
if not downloaded and not read_badge then return cover end
local group = OverlapGroup:new{
@@ -648,7 +702,7 @@ function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, sta
cover,
}
if downloaded then
local badge = CatalogWidgets.buildDownloadedBadge(math.floor(math.min(width, height) * 0.18))
local badge = CatalogWidgets.buildDownloadedBadge(badge_w)
badge.overlap_align = "left"
table.insert(group, badge)
end
@@ -659,36 +713,49 @@ function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, sta
return group
end
local SECTION_HEADER_FONT_SIZE = 14
-- The height of a section header's label row. Header controls are built at
-- exactly this size, so a header carrying chevrons or a reroll button is no
-- taller than a bare one: every section keeps the same label-to-underline
-- distance, and the dashboard can budget one header height for all of them.
function CatalogWidgets.dashboardSectionHeaderRowHeight()
local probe = TextWidget:new{
text = "X",
face = Font:getFace("cfont", SECTION_HEADER_FONT_SIZE),
bold = true,
}
local height = probe:getSize().h
probe:free()
return height
end
-- A dashboard section header in the detail page's tab idiom: an uppercase
-- bold label sitting on a thick underline that runs out into a hairline rule.
-- An optional control (e.g. the Discover reroll button) is pinned to the
-- right edge, vertically centered on the label row.
-- Optional controls (paging chevrons, the Discover reroll) are pinned flush to
-- the right edge and fill the label row, so they read as part of the header
-- rather than floating above it.
function CatalogWidgets.buildDashboardSectionHeader(text, width, right_widget)
local face = Font:getFace("cfont", 14)
local right
if right_widget then
right = HorizontalGroup:new{
align = "center",
right_widget,
HorizontalSpan:new{ width = Screen:scaleBySize(6) },
}
end
local right_w = right and right:getSize().w or 0
local gap = right and (Size.span.horizontal_default + Screen:scaleBySize(4)) or 0
local right_w = right_widget and right_widget:getSize().w or 0
local gap = right_widget and Size.span.horizontal_default or 0
local label = TextWidget:new{
text = string.upper(text or ""),
face = face,
face = Font:getFace("cfont", SECTION_HEADER_FONT_SIZE),
bold = true,
max_width = math.max(1, width - right_w - gap),
}
local label_h = math.max(label:getSize().h, right and right:getSize().h or 0)
local row_dimen = Geom:new{ w = width, h = label_h }
-- Controls are sized to the row, but a caller that hands over something
-- taller must not have it clipped.
local row_h = math.max(
CatalogWidgets.dashboardSectionHeaderRowHeight(),
right_widget and right_widget:getSize().h or 0)
local row_dimen = Geom:new{ w = width, h = row_h }
local row = OverlapGroup:new{
dimen = row_dimen:copy(),
LeftContainer:new{ dimen = row_dimen:copy(), label },
}
if right then
table.insert(row, RightContainer:new{ dimen = row_dimen:copy(), right })
if right_widget then
table.insert(row, RightContainer:new{ dimen = row_dimen:copy(), right_widget })
end
local underline_w = math.min(width, label:getSize().w + Screen:scaleBySize(8))
@@ -711,28 +778,111 @@ function CatalogWidgets.buildDashboardSectionHeader(text, width, right_widget)
}
end
local STAT_VALUE_FONT_SIZE = 18
local STAT_LABEL_FONT_SIZE = 10
-- The row heights every stats block shares. Measuring once and handing the
-- same metrics to each block is what keeps the strip on a grid: the value row
-- and the label row land on the same two baselines whether or not a block
-- carries a sparkline, instead of each block being centered on its own height.
function CatalogWidgets.dashboardStatMetrics(spark_h)
local value_probe = TextWidget:new{
text = "0",
face = Font:getFace("cfont", STAT_VALUE_FONT_SIZE),
bold = true,
}
local label_probe = TextWidget:new{
text = "X",
face = Font:getFace("cfont", STAT_LABEL_FONT_SIZE),
}
local metrics = {
value_h = value_probe:getSize().h,
label_h = label_probe:getSize().h,
spark_h = spark_h or 0,
gap = Size.span.vertical_default,
}
value_probe:free()
label_probe:free()
metrics.total_h = metrics.value_h + metrics.gap + metrics.label_h
+ (metrics.spark_h > 0 and (metrics.spark_h + metrics.gap) or 0)
return metrics
end
-- One block of the dashboard stats strip: a big bold value over a compact
-- muted uppercase label, both centered in the given width.
function CatalogWidgets.buildDashboardStat(value, label, width)
local value_face = Font:getFace("cfont", 18)
local label_face = Font:getFace("cfont", 10)
return VerticalGroup:new{
-- muted uppercase label, both centered in the given width. The sparkline row
-- is reserved in every block, so blocks without one still align.
function CatalogWidgets.buildDashboardStat(value, label, width, extra, metrics)
metrics = metrics or CatalogWidgets.dashboardStatMetrics(extra and extra:getSize().h or 0)
local col = VerticalGroup:new{
align = "center",
CenterContainer:new{
dimen = Geom:new{ w = width, h = lineHeight(value_face) },
TextWidget:new{ text = value or "", face = value_face, bold = true, max_width = width },
},
VerticalSpan:new{ width = Size.span.vertical_default },
CenterContainer:new{
dimen = Geom:new{ w = width, h = lineHeight(label_face) },
dimen = Geom:new{ w = width, h = metrics.value_h },
TextWidget:new{
text = string.upper(label or ""),
face = label_face,
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
text = value or "",
face = Font:getFace("cfont", STAT_VALUE_FONT_SIZE),
bold = true,
max_width = width,
},
},
}
if metrics.spark_h > 0 then
table.insert(col, VerticalSpan:new{ width = metrics.gap })
if extra then
table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = width, h = metrics.spark_h },
extra,
})
else
table.insert(col, VerticalSpan:new{ width = metrics.spark_h })
end
end
table.insert(col, VerticalSpan:new{ width = metrics.gap })
table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = width, h = metrics.label_h },
TextWidget:new{
text = string.upper(label or ""),
face = Font:getFace("cfont", STAT_LABEL_FONT_SIZE),
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
max_width = width,
},
})
return col
end
-- One day of the seven-day activity chart. A day with no reading contributes
-- only its column width: the shared baseline underneath is what shows it as an
-- empty day, so quiet days do not need marks of their own.
function CatalogWidgets.buildDashboardWeekBar(width, height, fill_height, is_today)
fill_height = math.max(0, math.min(height, fill_height or 0))
if fill_height <= 0 then
return HorizontalSpan:new{ width = width }
end
return LineWidget:new{
background = is_today and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
dimen = Geom:new{ w = width, h = fill_height },
}
end
-- The marker a stats block without a sparkline puts in that same row, so all
-- four blocks carry something between their value and their label instead of
-- three of them showing a gap. Mirrors the web dashboard's widget icons.
function CatalogWidgets.buildDashboardStatIcon(asset_name, size)
local file = CatalogWidgets.assetIconFile(asset_name)
if not file then return nil end
return IconWidget:new{ file = file, width = size, height = size }
end
-- The bars sit on a light rule spanning the whole week, which is what makes the
-- row read as a chart with an axis rather than as marks floating in the strip.
function CatalogWidgets.buildDashboardWeekChart(bars)
return VerticalGroup:new{
align = "center",
bars,
LineWidget:new{
background = Blitbuffer.COLOR_LIGHT_GRAY,
dimen = Geom:new{ w = bars:getSize().w, h = Size.line.medium },
},
}
end
-- A muted status line (Updated / offline cache / unavailable).
@@ -786,8 +936,10 @@ function DashboardCoverCard:init()
local col = VerticalGroup:new{ align = "center" }
table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = cover_h },
CatalogWidgets.buildDashboardCoverWidget(
book, cover_w, cover_h, path, state, downloaded, self.quiet_placeholder == true),
CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state, downloaded, {
quiet_placeholder = self.quiet_placeholder == true,
no_border = true,
}),
})
if reserve_progress then
table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default })
@@ -939,6 +1091,86 @@ function DashboardHeroCard:onHoldSelect()
return true
end
local HIGHLIGHT_QUOTE_FONT_SIZE = 15
local HIGHLIGHT_QUOTE_MAX_LINES = 4
local HIGHLIGHT_ATTRIBUTION_FONT_SIZE = 11
-- The quote block inside the Highlight of the day card: the highlighted text
-- over a muted attribution line. Built separately so the layout can measure
-- the card before committing vertical budget to it.
function CatalogWidgets.buildDashboardHighlightContent(highlight, width)
highlight = highlight or {}
local quote_face = Font:getFace("cfont", HIGHLIGHT_QUOTE_FONT_SIZE)
local attribution_face = Font:getFace("cfont", HIGHLIGHT_ATTRIBUTION_FONT_SIZE)
local content = VerticalGroup:new{ align = "left" }
table.insert(content, TextBoxWidget:new{
text = BD.auto("“" .. shortText(highlight.text or "", 240) .. "”"),
width = width,
height = HIGHLIGHT_QUOTE_MAX_LINES * lineHeight(quote_face),
height_adjust = true,
height_overflow_show_ellipsis = true,
face = quote_face,
})
local attribution_parts = {}
if highlight.bookTitle and highlight.bookTitle ~= "" then
table.insert(attribution_parts, shortText(highlight.bookTitle, 60))
end
if highlight.chapterTitle and highlight.chapterTitle ~= "" then
table.insert(attribution_parts, shortText(highlight.chapterTitle, 40))
end
if #attribution_parts > 0 then
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(content, TextWidget:new{
text = BD.auto(table.concat(attribution_parts, " - ")),
face = attribution_face,
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
max_width = width,
})
end
return content
end
function CatalogWidgets.dashboardHighlightCardHeight(highlight, card_w)
local pad = Size.padding.default
local inner_w = math.max(1, card_w - 2 * pad - 2 * CARD_BORDER)
local content = CatalogWidgets.buildDashboardHighlightContent(highlight, inner_w)
return content:getSize().h + 2 * pad + 2 * CARD_BORDER
end
-- The Highlight of the day card: a quote card in the shared dashboard chrome
-- that taps through to the highlighted book like any other dashboard card.
local DashboardHighlightCard = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
}
function DashboardHighlightCard:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
}
local pad = Size.padding.default
local inner_w = math.max(1, self.dimen.w - 2 * pad - 2 * CARD_BORDER)
local inner_h = math.max(1, self.dimen.h - 2 * pad - 2 * CARD_BORDER)
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, LeftContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
CatalogWidgets.buildDashboardHighlightContent(self.entry.highlight, inner_w),
})
end
function DashboardHighlightCard:onTapSelect()
if self.entry.book_id then
self.menu:onMenuSelect(self.entry)
end
return true
end
function DashboardHighlightCard:onHoldSelect()
return self:onTapSelect()
end
-- A compact related-book card for the detail page shelves: cover only, with
-- the same tap/hold behavior as normal catalog cards.
local DetailRelatedCard = InputContainer:extend{
@@ -965,7 +1197,8 @@ function DetailRelatedCard:init()
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
CatalogWidgets.buildDashboardCoverWidget(book, inner_w, inner_h, path, state, downloaded),
CatalogWidgets.buildDashboardCoverWidget(
book, inner_w, inner_h, path, state, downloaded, { no_border = true }),
})
end
@@ -1115,14 +1348,19 @@ function DashboardBrowseRow:init()
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
}
end
local count_w = count_widget and count_box_w or 0
-- Wide counts (a four-digit library) get the width they measure at instead
-- of being clipped to the nominal box.
local count_w = count_widget and math.max(count_box_w, count_widget:getSize().w + Size.padding.small) or 0
local label_w = math.max(1, self.dimen.w - icon_box_w - gap - count_w)
local left = HorizontalGroup:new{ align = "center" }
if entry.icon then
if entry.icon or entry.icon_file then
local icon_opts = iconOrFile(entry.icon, entry.icon_file)
icon_opts.width = icon_size
icon_opts.height = icon_size
table.insert(left, CenterContainer:new{
dimen = Geom:new{ w = icon_box_w, h = inner_h },
IconWidget:new{ icon = entry.icon, width = icon_size, height = icon_size },
IconWidget:new(icon_opts),
})
else
table.insert(left, HorizontalSpan:new{ width = icon_box_w })
@@ -1142,7 +1380,7 @@ function DashboardBrowseRow:init()
if count_widget then
table.insert(row, RightContainer:new{
dimen = row_dimen:copy(),
CenterContainer:new{ dimen = Geom:new{ w = count_box_w, h = inner_h }, count_widget },
CenterContainer:new{ dimen = Geom:new{ w = count_w, h = inner_h }, count_widget },
})
end
@@ -1166,39 +1404,71 @@ function DashboardBrowseRow:onHoldSelect()
return true
end
-- A small tappable icon button, used for the Discover section's reroll control.
-- A small tappable icon button, used for the section header controls (paging
-- chevrons, the Discover reroll). The glyph can stay smaller than the tappable
-- box via icon_size, and entry.icon_file swaps in one of the plugin's own SVG
-- icons. A disabled button dims its glyph and swallows the tap.
--
-- tap_padding_v grows the tap area above and below the drawn box without
-- making the widget itself taller, so a header control can sit inside the
-- label row and still take a finger-sized tap from the surrounding gap.
local DashboardIconButton = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
icon_size = nil,
enabled = true,
callback = nil,
tap_padding_v = 0,
}
function DashboardIconButton:init()
-- Evaluated at gesture time: self.dimen only gets its screen position when
-- the widget is painted.
local function tapRange()
local pad = self.tap_padding_v
if pad <= 0 then return self.dimen end
return Geom:new{
x = self.dimen.x,
y = self.dimen.y - pad,
w = self.dimen.w,
h = self.dimen.h + 2 * pad,
}
end
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
TapSelect = { GestureRange:new{ ges = "tap", range = tapRange } },
HoldSelect = { GestureRange:new{ ges = "hold", range = tapRange } },
}
local icon_size = math.max(1, math.min(self.dimen.w, self.dimen.h) - Size.padding.tiny)
local icon_size = self.icon_size or math.max(1, math.min(self.dimen.w, self.dimen.h) - Size.padding.tiny)
local icon_opts = iconOrFile(self.entry and self.entry.icon, self.entry and self.entry.icon_file)
icon_opts.width = icon_size
icon_opts.height = icon_size
icon_opts.dim = not self.enabled
self[1] = CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
IconWidget:new{ icon = self.entry.icon, width = icon_size, height = icon_size },
IconWidget:new(icon_opts),
}
end
function DashboardIconButton:onTapSelect()
self.menu:onMenuSelect(self.entry)
if not self.enabled then return true end
if self.callback then
self.callback()
else
self.menu:onMenuSelect(self.entry)
end
return true
end
function DashboardIconButton:onHoldSelect()
self.menu:onMenuSelect(self.entry)
return true
return self:onTapSelect()
end
CatalogWidgets.MosaicItem = MosaicItem
CatalogWidgets.ListItem = ListItem
CatalogWidgets.DashboardCoverCard = DashboardCoverCard
CatalogWidgets.DashboardHeroCard = DashboardHeroCard
CatalogWidgets.DashboardHighlightCard = DashboardHighlightCard
CatalogWidgets.DetailRelatedCard = DetailRelatedCard
CatalogWidgets.DetailTabButton = DetailTabButton
CatalogWidgets.DetailRatingStar = DetailRatingStar
@@ -35,8 +35,11 @@ DashboardSections.TYPES = {
"continue-reading",
"random",
"browse",
"highlight",
"recently-added",
"in-progress",
"want-to-read",
"up-next-in-series",
"libraries",
"authors",
"series",
@@ -44,13 +47,29 @@ DashboardSections.TYPES = {
"smart-scopes",
}
-- Sources served by the dashboard-section endpoint rather than the catalog
-- books endpoint; only offered when the server advertises the capability.
DashboardSections.SECTION_ENDPOINT_CAPABILITY = "catalogDashboardSections"
local SECTION_ENDPOINT_TYPES = {
["want-to-read"] = true,
["up-next-in-series"] = true,
}
function DashboardSections.usesSectionEndpoint(section_type)
return SECTION_ENDPOINT_TYPES[section_type] == true
end
local LABELS = {
["stats"] = function() return _("Stats") end,
["continue-reading"] = function() return _("Continue reading") end,
["random"] = function() return _("Discover") end,
["browse"] = function() return _("Browse") end,
["highlight"] = function() return _("Highlight of the day") end,
["recently-added"] = function() return _("Recently added") end,
["in-progress"] = function() return _("In progress") end,
["want-to-read"] = function() return _("Want to read") end,
["up-next-in-series"] = function() return _("Up next in series") end,
["libraries"] = function() return _("Libraries") end,
["authors"] = function() return _("Authors") end,
["series"] = function() return _("Series") end,
@@ -63,8 +82,11 @@ local HELP_TEXT = {
["continue-reading"] = function() return _("Books currently in progress, shown as the dashboard hero row.") end,
["random"] = function() return _("A random selection from your whole library, reshuffled with the button in the section header.") end,
["browse"] = function() return _("Dashboard shortcuts for libraries, collections, SmartScopes, authors, series, and books.") end,
["highlight"] = function() return _("One highlight from your library, picked fresh every day.") end,
["recently-added"] = function() return _("The books most recently added to your library.") end,
["in-progress"] = function() return _("The catalog view for books currently in progress.") end,
["want-to-read"] = function() return _("Books marked Want to read. Needs a server that supports dashboard sections.") end,
["up-next-in-series"] = function() return _("The next unread book of each series you are reading. Needs a server that supports dashboard sections.") end,
["libraries"] = function() return _("The existing Libraries catalog, including its normal navigation and back button.") end,
["authors"] = function() return _("The existing Authors catalog, including its normal pagination and back button.") end,
["series"] = function() return _("The existing Series catalog, including its normal pagination and back button.") end,
@@ -90,6 +112,8 @@ local BOOK_SOURCES = {
["random"] = true,
["recently-added"] = true,
["in-progress"] = true,
["want-to-read"] = true,
["up-next-in-series"] = true,
["libraries"] = true,
["authors"] = true,
["series"] = true,
@@ -19,6 +19,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitCapabilities = require("bookorbit_capabilities")
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
local BookOrbitSweep = require("bookorbit_sweep")
local DashboardSections = require("bookorbit_dashboard_sections")
@@ -346,26 +347,44 @@ function MainMenu:chooseDashboardCatalogSource(index, section, catalog, touchmen
end)
end
-- The capability list recorded for the configured server, when one is known.
-- Nil (still unknown) keeps every source on offer; the dashboard downgrades
-- the capability itself when the server answers 404.
function MainMenu:knownServerCapabilities(catalog)
local client = catalog and catalog.client
if not client and self.newClient then
local ok, built = pcall(function() return self:newClient() end)
client = ok and built or nil
end
return client and BookOrbitCapabilities.cached(client) or nil
end
function MainMenu:dashboardSectionItems(index, catalog)
local items = {}
local known_capabilities = self:knownServerCapabilities(catalog)
for _index, section_type in ipairs(DashboardSections.TYPES) do
local unsupported = DashboardSections.usesSectionEndpoint(section_type)
and known_capabilities ~= nil
and known_capabilities[DashboardSections.SECTION_ENDPOINT_CAPABILITY] ~= true
local is_selector = DashboardSections.isCatalogSelector(section_type)
table.insert(items, {
text = DashboardSections.label(section_type),
mandatory = is_selector and ">" or nil,
help_text = DashboardSections.helpText(section_type),
checked_func = is_selector and nil or function()
return DashboardSections.at(self.settings, index).type == section_type
end,
keep_menu_open = is_selector,
callback = function(touchmenu_instance)
if is_selector then
self:chooseDashboardCatalogSource(index, section_type, catalog, touchmenu_instance)
else
self:applyDashboardSection(index, { type = section_type }, catalog, touchmenu_instance)
end
end,
})
if not unsupported then
table.insert(items, {
text = DashboardSections.label(section_type),
mandatory = is_selector and ">" or nil,
help_text = DashboardSections.helpText(section_type),
checked_func = is_selector and nil or function()
return DashboardSections.at(self.settings, index).type == section_type
end,
keep_menu_open = is_selector,
callback = function(touchmenu_instance)
if is_selector then
self:chooseDashboardCatalogSource(index, section_type, catalog, touchmenu_instance)
else
self:applyDashboardSection(index, { type = section_type }, catalog, touchmenu_instance)
end
end,
})
end
end
return items
end
@@ -378,6 +397,16 @@ function MainMenu:dashboardSettingsMenu(catalog)
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
{
text = _("Tap Continue reading to open the book"),
checked_func = function()
return self.settings.catalog_dashboard_tap_resumes ~= false
end,
help_text = _([[Tapping a book under Continue reading opens it directly when it is already on the device; hold for its actions. When disabled, tapping opens the book's details instead.]]),
callback = function()
self.settings.catalog_dashboard_tap_resumes = self.settings.catalog_dashboard_tap_resumes == false
end,
},
}
for index = 1, DashboardSections.SLOT_COUNT do
table.insert(items, {
@@ -236,8 +236,9 @@ function Session:eventsAfter(ids, watermark, limit)
end
-- Aggregated local reading activity for the dashboard stats strip: seconds
-- read today and over the past 7 days, plus the current daily reading streak
-- (consecutive days with any reading, still counting if today has none yet).
-- read today, over the past 7 days and per day of that week, plus the current
-- daily reading streak (consecutive days with any reading, still counting if
-- today has none yet).
function Session:readingSummary()
local today = os.date("*t")
today.hour, today.min, today.sec = 0, 0, 0
@@ -249,6 +250,24 @@ function Session:readingSummary()
return res and tonumber(res[1][1]) or 0
end
-- Per-day totals for the strip's activity bars, oldest first, today last.
local day_seconds = { 0, 0, 0, 0, 0, 0, 0 }
local week_start = day_start - 6 * 86400
local day_res = self:select(
"SELECT date(start_time, 'unixepoch', 'localtime'), COALESCE(SUM(MIN(duration, 86400)), 0)"
.. " FROM page_stat_data WHERE start_time >= ? GROUP BY 1;",
{ week_start })
if day_res then
local index_by_date = {}
for day = 1, 7 do
index_by_date[os.date("%Y-%m-%d", week_start + (day - 1) * 86400)] = day
end
for i = 1, #day_res[1] do
local day = index_by_date[day_res[1][i]]
if day then day_seconds[day] = tonumber(day_res[2][i]) or 0 end
end
end
local streak = 0
local res = self:select(
"SELECT DISTINCT date(start_time, 'unixepoch', 'localtime') FROM page_stat_data WHERE start_time >= ?;",
@@ -271,6 +290,7 @@ function Session:readingSummary()
return {
today_seconds = sumSince(day_start),
week_seconds = sumSince(day_start - 6 * 86400),
day_seconds = day_seconds,
streak_days = streak,
}
end