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.
@@ -2,7 +2,7 @@
|
||||
|
||||
Syncs your KOReader reading life into BookOrbit:
|
||||
|
||||
- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search, plus one configurable book row you choose the source of. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device.
|
||||
- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search, built from four configurable sections you choose the sources of. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device.
|
||||
- **Progress sync**: pulls progress on book open (with a conflict prompt), pushes periodically and on close/suspend.
|
||||
- **Reading statistics**: uploads KOReader's per-page time events; BookOrbit turns them into reading sessions and daily stats.
|
||||
- **Two-way highlights**: device highlights appear as native highlights on the web reader; highlights, edits and deletions made on the web come back to the device.
|
||||
@@ -31,7 +31,7 @@ Re-downloading later (e.g. after changing your password or server address) and r
|
||||
1. Tools > BookOrbit > "Auto sync current book" to sync the open book automatically. Leave the stock "Progress sync" plugin unconfigured to avoid double syncing.
|
||||
2. Optional: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
|
||||
3. Optional: Settings > Dashboard > "Open dashboard on startup".
|
||||
4. Optional: Settings > Dashboard > "Show below Continue reading" - pick Discover (the default), Recently added, Want to read, Up next in series, or one of your SmartScopes. Needs a server new enough to advertise the section capability; older servers keep showing Discover.
|
||||
4. Optional: Settings > Dashboard > Section 1-4 - pick each dashboard section's source: Stats, Continue reading, Discover, Browse, Highlight of the day, Recently added, In progress, Want to read, Up next in series, or a pick from the Libraries/Authors/Series/Collections/SmartScopes catalogs. Want to read and Up next in series need a server new enough to advertise the section capability; against an older server they render empty and stop being offered. "Tap Continue reading to open the book" (on by default) resumes an on-device book in one tap; hold for its actions.
|
||||
5. Optional: assign "BookOrbit: sync this book" / "sync all books" to gestures.
|
||||
|
||||
The plugin manages `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` to keep its menu entry pinned below Calibre across updates; delete those files to reset your menu order.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,9 +65,11 @@ package.loaded["ltn12"] = {
|
||||
},
|
||||
}
|
||||
|
||||
local last_request_url
|
||||
package.loaded["socket.http"] = {
|
||||
request = function(request)
|
||||
request_ran_in_subprocess = in_subprocess
|
||||
last_request_url = request.url
|
||||
if mock_http_body then
|
||||
request.sink(mock_http_body)
|
||||
end
|
||||
@@ -157,6 +159,13 @@ assertEqual(body, nil, "HTTP error has no decoded body")
|
||||
assertEqual(err, 503, "HTTP error preserves status code")
|
||||
assertEqual(errbody, nil, "invalid HTTP error body is ignored")
|
||||
|
||||
mock_http_body = "{\"ok\":true}"
|
||||
mock_http_code = 200
|
||||
client:catalogDashboardSection("up-next-in-series")
|
||||
assertEqual(last_request_url,
|
||||
"https://bookorbit.example.com/api/v1/koreader/plugin/catalog/dashboard/sections/up-next-in-series",
|
||||
"the dashboard-section endpoint is addressed by source type")
|
||||
|
||||
local wrapped = true
|
||||
local subprocess_calls = 0
|
||||
local subprocess_result_mode = "normal"
|
||||
|
||||
@@ -71,6 +71,7 @@ package.loaded["ui/size"] = {
|
||||
span = { horizontal_default = 4, vertical_default = 4 },
|
||||
}
|
||||
package.loaded["ui/widget/textboxwidget"] = widgetClass()
|
||||
package.loaded["ui/widget/textwidget"] = widgetClass()
|
||||
package.loaded["ui/uimanager"] = {
|
||||
show = function() end,
|
||||
}
|
||||
@@ -142,6 +143,11 @@ local function newCatalog(opts)
|
||||
if opts.discover_error then return nil, opts.discover_error end
|
||||
return { discover = { { id = 14 } } }
|
||||
end,
|
||||
catalogDashboardSection = function(_, section_type)
|
||||
table.insert(requests, "section:" .. tostring(section_type))
|
||||
if opts.section_endpoint_error then return nil, opts.section_endpoint_error end
|
||||
return { section = { type = section_type, books = { { id = 21 } } } }
|
||||
end,
|
||||
},
|
||||
}
|
||||
for name, fn in pairs(CatalogDashboard) do
|
||||
@@ -278,6 +284,50 @@ assertEqual(partial.dashboard ~= nil, true, "one shelf failure does not discard
|
||||
assertEqual(#partial.dashboard.dashboardSlots[2].books, 0, "the failed shelf degrades to an empty list")
|
||||
assertEqual(partial.dashboard.dashboardSlots[3].books[1].id, 12, "later shelves continue loading")
|
||||
|
||||
-- Server-composed sources go through the dashboard-section endpoint; distinct
|
||||
-- sources cost one request each and land in their slots.
|
||||
local Capabilities = require("bookorbit_capabilities")
|
||||
Capabilities.reset()
|
||||
catalog = newCatalog{
|
||||
sections = {
|
||||
{ type = "stats" },
|
||||
{ type = "want-to-read" },
|
||||
{ type = "up-next-in-series" },
|
||||
{ type = "browse" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
},
|
||||
}
|
||||
local _, server_sections = catalog:dashboardRoot()
|
||||
assertEqual(requests[2], "section:want-to-read", "Want to read fetches through the section endpoint")
|
||||
assertEqual(requests[3], "section:up-next-in-series", "Up next in series fetches through the section endpoint")
|
||||
assertEqual(server_sections.dashboard.dashboardSlots[2].books[1].id, 21, "the section endpoint's books land in the slot")
|
||||
|
||||
-- A 404 from the section endpoint downgrades the capability: the shelf
|
||||
-- degrades to empty, and later fetches skip the endpoint entirely.
|
||||
Capabilities.reset()
|
||||
catalog = newCatalog{
|
||||
sections = {
|
||||
{ type = "stats" },
|
||||
{ type = "want-to-read" },
|
||||
{ type = "random" },
|
||||
{ type = "browse" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
},
|
||||
section_endpoint_error = 404,
|
||||
}
|
||||
local _, downgraded = catalog:dashboardRoot()
|
||||
assertEqual(#downgraded.dashboard.dashboardSlots[2].books, 0, "an unsupported section degrades to an empty shelf")
|
||||
assertEqual(Capabilities.cached(catalog.client)[DashboardSections.SECTION_ENDPOINT_CAPABILITY], false,
|
||||
"a confirmed 404 records the missing capability")
|
||||
requests = {}
|
||||
catalog:dashboardRoot()
|
||||
local section_requests = 0
|
||||
for _, request in ipairs(requests) do
|
||||
if request:find("^section:") then section_requests = section_requests + 1 end
|
||||
end
|
||||
assertEqual(section_requests, 0, "a known-missing capability skips the section endpoint")
|
||||
Capabilities.reset()
|
||||
|
||||
-- A cached body fetched for another four-slot configuration is still shown, but
|
||||
-- its grid shelves are marked pending until the refresh lands.
|
||||
catalog = newCatalog{
|
||||
|
||||
@@ -68,6 +68,7 @@ package.loaded["ui/size"] = {
|
||||
span = { horizontal_default = 4, vertical_default = 4 },
|
||||
}
|
||||
package.loaded["ui/widget/textboxwidget"] = widgetClass()
|
||||
package.loaded["ui/widget/textwidget"] = widgetClass()
|
||||
package.loaded["ui/uimanager"] = {
|
||||
show = function() end,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,21 @@ assertEqual(
|
||||
assertEqual(CatalogUtil.readingStreakDays({}, 14), 14, "missing account streak falls back to local streak")
|
||||
assertEqual(CatalogUtil.readingStreakDays(nil, nil), 0, "missing streak data falls back to zero")
|
||||
|
||||
-- Relative labels pick their bucket from the delta; the stubbed template
|
||||
-- returns the raw format string, which is enough to prove branch selection.
|
||||
local now = os.time()
|
||||
local function iso(timestamp)
|
||||
return os.date("!%Y-%m-%dT%H:%M:%S", timestamp) .. ".000Z"
|
||||
end
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now - 30), now), "just now", "a fresh timestamp reads as just now")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now - 300), now), "%1 min ago", "minutes bucket")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now - 7200), now), "%1 h ago", "hours bucket")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now - math.floor(1.5 * 86400)), now), "yesterday", "one day reads as yesterday")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now - 5 * 86400), now), "%1 days ago", "days bucket")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(iso(now + 3600), now), "just now", "a future timestamp clamps to just now")
|
||||
assertEqual(CatalogUtil.formatRelativeTime("not a timestamp", now), nil, "junk yields no label")
|
||||
assertEqual(CatalogUtil.formatRelativeTime(nil, now), nil, "nil yields no label")
|
||||
|
||||
local function widgetClass()
|
||||
return {
|
||||
new = function(_, opts)
|
||||
@@ -90,6 +105,7 @@ package.loaded["ui/size"] = {
|
||||
},
|
||||
}
|
||||
package.loaded["ui/widget/textboxwidget"] = widgetClass()
|
||||
package.loaded["ui/widget/textwidget"] = widgetClass()
|
||||
package.loaded["ui/uimanager"] = {}
|
||||
package.loaded["ui/widget/verticalgroup"] = widgetClass()
|
||||
package.loaded["ui/widget/verticalspan"] = widgetClass()
|
||||
@@ -97,19 +113,37 @@ package.loaded["bookorbit_stats_reader"] = {}
|
||||
|
||||
local rendered_stats = {}
|
||||
package.loaded["bookorbit_catalog_widgets"] = {
|
||||
buildDashboardStat = function(value, label, width)
|
||||
table.insert(rendered_stats, { value = value, label = label, width = width })
|
||||
buildDashboardStat = function(value, label, width, extra, metrics)
|
||||
table.insert(rendered_stats,
|
||||
{ value = value, label = label, width = width, extra = extra, metrics = metrics })
|
||||
return widgetClass():new()
|
||||
end,
|
||||
dashboardStatMetrics = function(spark_h)
|
||||
return { value_h = 20, label_h = 10, spark_h = spark_h or 0, gap = 4, total_h = 34 }
|
||||
end,
|
||||
buildDashboardWeekBar = function()
|
||||
return widgetClass():new()
|
||||
end,
|
||||
buildDashboardWeekChart = function(bars)
|
||||
return bars
|
||||
end,
|
||||
buildDashboardStatIcon = function(asset_name)
|
||||
return widgetClass():new{ asset_name = asset_name }
|
||||
end,
|
||||
}
|
||||
|
||||
local CatalogDashboard = require("bookorbit_catalog_dashboard")
|
||||
CatalogDashboard.buildDashboardStatsStrip({
|
||||
content_w = 500,
|
||||
onDeviceCount = function()
|
||||
return 2
|
||||
end,
|
||||
}, {
|
||||
|
||||
local function newStripCatalog()
|
||||
local catalog = { content_w = 500 }
|
||||
for name, fn in pairs(CatalogDashboard) do
|
||||
if name ~= "install" then catalog[name] = fn end
|
||||
end
|
||||
return catalog
|
||||
end
|
||||
|
||||
local catalog = newStripCatalog()
|
||||
catalog:buildDashboardStatsStrip({
|
||||
today_seconds = 600,
|
||||
week_seconds = 3600,
|
||||
streak_days = 14,
|
||||
@@ -118,7 +152,56 @@ CatalogDashboard.buildDashboardStatsStrip({
|
||||
readingStreak = { currentStreak = 20 },
|
||||
})
|
||||
|
||||
assertEqual(#rendered_stats, 3, "without a goal the strip keeps to the three activity blocks")
|
||||
assertEqual(rendered_stats[1].label, "Today", "dashboard renders a today stat")
|
||||
assertEqual(rendered_stats[1].value, "%1 min", "an empty day renders as minutes, not prose")
|
||||
-- No per-day data means no chart, but the block still takes the fallback icon
|
||||
-- rather than becoming the one empty slot in the strip.
|
||||
assertEqual(rendered_stats[2].extra.asset_name, "stat_week",
|
||||
"without per-day data the week block falls back to its icon")
|
||||
assertEqual(rendered_stats[3].label, "Day streak", "dashboard renders a day streak stat")
|
||||
assertEqual(rendered_stats[3].value, "20", "dashboard renders the account streak instead of the local streak")
|
||||
|
||||
-- With per-day activity and a yearly goal the strip gains the bars and a
|
||||
-- fourth block; inventory counts stay out of it.
|
||||
rendered_stats = {}
|
||||
catalog = newStripCatalog()
|
||||
catalog:buildDashboardStatsStrip({
|
||||
today_seconds = 600,
|
||||
week_seconds = 3600,
|
||||
day_seconds = { 0, 600, 0, 1200, 0, 900, 600 },
|
||||
streak_days = 14,
|
||||
}, {
|
||||
totalBooks = 99,
|
||||
readingStreak = { currentStreak = 20 },
|
||||
readingGoal = { goalBooks = 24, completedBooks = 12, year = 2026 },
|
||||
})
|
||||
|
||||
assertEqual(#rendered_stats, 4, "a yearly goal adds a fourth block")
|
||||
assertEqual(rendered_stats[2].extra ~= nil, true, "per-day activity renders the bars")
|
||||
assertEqual(rendered_stats[4].value, "12 / 24", "the goal block shows progress toward the yearly goal")
|
||||
|
||||
-- Every block is laid out against one set of row heights, which is what keeps
|
||||
-- the values on a single baseline and the labels on another even though only
|
||||
-- the "Past 7 days" block carries a sparkline.
|
||||
for index, block in ipairs(rendered_stats) do
|
||||
assertEqual(block.metrics, rendered_stats[1].metrics,
|
||||
"block " .. index .. " shares the strip's row metrics")
|
||||
end
|
||||
assertEqual(rendered_stats[1].metrics.spark_h > 0, true,
|
||||
"blocks without a sparkline still reserve its row")
|
||||
|
||||
-- Every block puts something in that row: the chart for "Past 7 days", an icon
|
||||
-- for the rest, so three of the four do not render as an empty gap.
|
||||
for index, block in ipairs(rendered_stats) do
|
||||
assertEqual(block.extra ~= nil, true, "block " .. index .. " carries a marker")
|
||||
end
|
||||
assertEqual(rendered_stats[1].extra.asset_name, "stat_today", "the today block takes the clock")
|
||||
assertEqual(rendered_stats[3].extra.asset_name, "stat_streak", "the streak block takes the flame")
|
||||
assertEqual(rendered_stats[4].extra.asset_name, "stat_goal", "the goal block takes the target")
|
||||
for _, block in ipairs(rendered_stats) do
|
||||
assertEqual(block.label == "Library" or block.label == "On device", false,
|
||||
"inventory counts no longer render in the strip")
|
||||
end
|
||||
|
||||
print("bookorbit_catalog_streak_test.lua: ok")
|
||||
|
||||
@@ -42,6 +42,7 @@ package.loaded["ui/size"] = {
|
||||
span = { horizontal_default = 4, vertical_default = 4 },
|
||||
}
|
||||
package.loaded["ui/widget/textboxwidget"] = widgetClass()
|
||||
package.loaded["ui/widget/textwidget"] = widgetClass()
|
||||
package.loaded["ui/uimanager"] = { show = function() end }
|
||||
package.loaded["ui/widget/verticalgroup"] = widgetClass(0)
|
||||
package.loaded["ui/widget/verticalspan"] = widgetClass()
|
||||
@@ -52,15 +53,27 @@ package.loaded["bookorbit_stats_reader"] = {
|
||||
}
|
||||
|
||||
local HEADER_HEIGHT = 20
|
||||
local HEADER_ROW_HEIGHT = 14
|
||||
local STATUS_LINE_HEIGHT = 16
|
||||
local HIGHLIGHT_CARD_HEIGHT = 64
|
||||
|
||||
package.loaded["bookorbit_catalog_widgets"] = {
|
||||
DashboardCoverCard = widgetClass(),
|
||||
DashboardHeroCard = widgetClass(),
|
||||
DashboardHighlightCard = widgetClass(),
|
||||
DashboardBrowseRow = widgetClass(),
|
||||
DashboardIconButton = widgetClass(),
|
||||
assetIconFile = function() return nil end,
|
||||
dashboardSectionHeaderRowHeight = function() return HEADER_ROW_HEIGHT end,
|
||||
buildDashboardSectionHeader = function()
|
||||
return { getSize = function() return { w = 100, h = HEADER_HEIGHT } end }
|
||||
end,
|
||||
buildStatusLabel = function()
|
||||
return { getSize = function() return { w = 100, h = STATUS_LINE_HEIGHT } end }
|
||||
end,
|
||||
dashboardHighlightCardHeight = function()
|
||||
return HIGHLIGHT_CARD_HEIGHT
|
||||
end,
|
||||
coverCardHeight = function(card_w)
|
||||
return card_w + 20
|
||||
end,
|
||||
@@ -185,4 +198,66 @@ catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves, 0, "a pending shelf renders no cover grid")
|
||||
assertEqual(catalog.headers[2], "Discover", "a pending shelf still shows its header")
|
||||
|
||||
-- A stale (cached) dashboard announces itself with a status line that takes
|
||||
-- part in the vertical budget.
|
||||
catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
catalog.current_context.stale = true
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(catalog.dash_used <= 800, true, "the status line stays inside the available height")
|
||||
local without_status = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
without_status:updateDashboardItems()
|
||||
assertEqual(catalog.dash_used, without_status.dash_used, "budget accounting is unchanged aside from the trailing spacer")
|
||||
|
||||
-- The Highlight of the day slot renders its measured card and drops after
|
||||
-- stats but before any shelf when the page gets tight.
|
||||
local WITH_HIGHLIGHT = {
|
||||
{ type = "stats" }, { type = "continue-reading" }, { type = "highlight" }, { type = "random" },
|
||||
}
|
||||
catalog = newCatalog{ sections = WITH_HIGHLIGHT, avail = 800 }
|
||||
catalog.current_context.dashboard.highlightOfTheDay = { text = "quote", bookId = 7 }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(catalog.headers[2], "Highlight of the day", "the highlight slot renders its header")
|
||||
assertEqual(#catalog.shelves, 1, "the shelf renders alongside the highlight card")
|
||||
assertEqual(catalog.dash_used <= 800, true, "the highlight card stays inside the available height")
|
||||
|
||||
catalog = newCatalog{ sections = WITH_HIGHLIGHT, avail = 340 }
|
||||
catalog.current_context.dashboard.highlightOfTheDay = { text = "quote", bookId = 7 }
|
||||
catalog:updateDashboardItems()
|
||||
local saw_highlight = false
|
||||
for _, header in ipairs(catalog.headers) do
|
||||
if header == "Highlight of the day" then saw_highlight = true end
|
||||
end
|
||||
assertEqual(saw_highlight, false, "a tight page drops the highlight card after stats, before shelves")
|
||||
assertEqual(#catalog.shelves, 1, "the shelf survives the tight page")
|
||||
|
||||
-- Header controls are built at the header's label-row height, so a section
|
||||
-- carrying chevrons or a reroll button is exactly as tall as a bare one. That
|
||||
-- is what keeps the single measured header height the budget uses honest, and
|
||||
-- what stops the controls from floating above the label.
|
||||
catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
local prev_control, next_control = catalog:buildDashboardHeaderNav("section3", 2, 4)
|
||||
local reroll_control = catalog:buildDashboardRerollButton(3)
|
||||
for label, control in pairs({ prev = prev_control, next = next_control, reroll = reroll_control }) do
|
||||
assertEqual(control.dimen.h, HEADER_ROW_HEIGHT, label .. " control fills the header row exactly")
|
||||
assertEqual(control.dimen.w, HEADER_ROW_HEIGHT, label .. " control is square")
|
||||
assertEqual(control.tap_padding_v > 0, true, label .. " control takes taps from the gap around it")
|
||||
end
|
||||
assertEqual(prev_control.enabled, true, "a page-2 back chevron is live")
|
||||
assertEqual(catalog:buildDashboardHeaderNav("section3", 1, 4).enabled, false,
|
||||
"the back chevron is disabled on the first page")
|
||||
|
||||
-- Page turns record each section's band and narrow the refresh to it.
|
||||
catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
catalog.dimen = { x = 0, y = 0, w = 520, h = 900 }
|
||||
function catalog:menuChromeHeight() return 30, 40 end
|
||||
catalog.dash_refresh_section = "section3"
|
||||
catalog:updateDashboardItems()
|
||||
local band = catalog.dash_section_bands["section3"]
|
||||
assertEqual(band ~= nil and band.h > 0, true, "the shelf records its rendered band")
|
||||
local region = catalog.custom_refresh_region
|
||||
assertEqual(region ~= nil, true, "a page turn narrows the refresh to the changed band")
|
||||
assertEqual(region.y, 30 + band.y - 4, "the refresh band sits below the title bar with its padding")
|
||||
assertEqual(region.h, band.h + 8, "the refresh band covers the section plus padding")
|
||||
assertEqual(catalog.dash_refresh_section, nil, "the page-turn hint is consumed by the rebuild")
|
||||
|
||||
print("bookorbit_dashboard_layout_test.lua: ok")
|
||||
|
||||
@@ -30,9 +30,21 @@ local author_source = {
|
||||
|
||||
assertEqual(DashboardSections.normalizeEntry(nil).type, "random", "a missing entry degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "not-a-source" }).type, "random", "an unknown type degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "random", "an obsolete pseudo-source degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "smart-scope" }).type, "random", "an obsolete pseudo-source degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "authors" }).type, "random", "an unselected catalog type degrades to Discover")
|
||||
|
||||
-- Server-composed sources and the highlight card are first-class slot types.
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "want-to-read", "Want to read is a supported source again")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "up-next-in-series" }).type, "up-next-in-series", "Up next in series is a supported source")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "highlight" }).type, "highlight", "the highlight card is a supported slot")
|
||||
assertEqual(DashboardSections.isBookSource("want-to-read"), true, "Want to read renders as a shelf")
|
||||
assertEqual(DashboardSections.isBookSource("up-next-in-series"), true, "Up next in series renders as a shelf")
|
||||
assertEqual(DashboardSections.isBookSource("highlight"), false, "the highlight card has its own renderer")
|
||||
assertEqual(DashboardSections.usesSectionEndpoint("want-to-read"), true, "Want to read is served by the section endpoint")
|
||||
assertEqual(DashboardSections.usesSectionEndpoint("up-next-in-series"), true, "Up next in series is served by the section endpoint")
|
||||
assertEqual(DashboardSections.usesSectionEndpoint("recently-added"), false, "catalog-books sources skip the section endpoint")
|
||||
assertEqual(DashboardSections.label("highlight"), "Highlight of the day", "the highlight slot is labelled for the picker")
|
||||
|
||||
local normalized_author = DashboardSections.normalizeEntry(author_source)
|
||||
assertEqual(normalized_author.type, "authors", "a selected author source is kept")
|
||||
assertEqual(normalized_author.sourceName, "Ursula K. Le Guin", "a selected author keeps its display name")
|
||||
@@ -72,7 +84,7 @@ local migrated_two = DashboardSections.normalize({
|
||||
author_source,
|
||||
schemaVersion = DashboardSections.LEGACY_SCHEMA_VERSION,
|
||||
})
|
||||
assertEqual(migrated_two[3].type, "random", "an obsolete old row migrates safely to Discover")
|
||||
assertEqual(migrated_two[3].type, "want-to-read", "a legacy Want to read row migrates to the supported source")
|
||||
assertEqual(migrated_two[4].type, "authors", "a selected catalog source survives migration")
|
||||
|
||||
local current = DashboardSections.normalize({
|
||||
|
||||
@@ -69,6 +69,9 @@ do
|
||||
local today = os.date("%Y-%m-%d")
|
||||
local yesterday = os.date("%Y-%m-%d", os.time() - 86400)
|
||||
local Reader = load(function(sql)
|
||||
if sql:find("GROUP BY 1", 1, true) then
|
||||
return FakeSqlite.resultSet{ { yesterday, "600" }, { today, "1800" } }
|
||||
end
|
||||
if sql:find("COALESCE(SUM(", 1, true) then
|
||||
return FakeSqlite.resultSet{ { "1800" } }
|
||||
end
|
||||
@@ -82,6 +85,9 @@ do
|
||||
assertEqual(summary.today_seconds, 1800, "today's reading time is summed")
|
||||
assertEqual(summary.week_seconds, 1800, "the week window is summed too")
|
||||
assertEqual(summary.streak_days, 2, "consecutive reading days build the streak")
|
||||
assertEqual(summary.day_seconds[7], 1800, "today lands in the last per-day bucket")
|
||||
assertEqual(summary.day_seconds[6], 600, "yesterday lands in the bucket before it")
|
||||
assertEqual(summary.day_seconds[1], 0, "days without reading stay zero")
|
||||
end
|
||||
|
||||
-- A busy database gets one reopen before the query is given up on.
|
||||
|
||||