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.
@@ -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
|
||||
|
||||