mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
e28f45452d
Layout breakpoints use the screen width at 1x, so Interface size scales text, icons and spacing without dropping Home columns or detail panes. Every page, sheet and widget picks a layout that fits instead of cutting text or leaving gaps; search becomes balanced list-row sections, Edit Home keeps hidden widgets, stock KOReader messages become BookOrbit toasts, and the plugin is 2.1.0.
297 lines
14 KiB
Lua
297 lines
14 KiB
Lua
--[[--
|
|
Sync status: the diagnostics list rewritten as an answer.
|
|
|
|
A verdict first (all synced, waiting, offline, or the last failure), three
|
|
numbers, what happened recently, then the fixes. The old key-value list stays
|
|
one tap down as Technical details.
|
|
]]
|
|
|
|
local NetworkMgr = require("ui/network/manager")
|
|
local UIManager = require("ui/uimanager")
|
|
local _ = require("gettext")
|
|
local T = require("ffi/util").template
|
|
|
|
local Activity = require("bookorbit_activity")
|
|
local BoardMetrics = require("bookorbit_board_metrics")
|
|
local BoardUI = require("bookorbit_board_ui")
|
|
local MenuPage = require("bookorbit_menu_page")
|
|
local MenuUI = require("bookorbit_menu_ui")
|
|
|
|
local INK = BoardMetrics.INK
|
|
local px = BoardMetrics.px
|
|
local text, vstack, hstack, flex, space, icon = BoardUI.text, BoardUI.vstack, BoardUI.hstack, BoardUI.flex, BoardUI.space, BoardUI.icon
|
|
|
|
local Status = {}
|
|
|
|
function Status.data(plugin)
|
|
local settings = plugin.settings
|
|
local summary = require("bookorbit_sweep").syncStatus() or {}
|
|
local outbox = plugin.getLifecycleOutboxStatus and plugin:getLifecycleOutboxStatus() or { count = 0 }
|
|
local coordinator = plugin.getSyncCoordinatorStatus and plugin:getSyncCoordinatorStatus() or { pending_count = 0 }
|
|
local last_sync = type(settings.last_sync) == "table" and settings.last_sync or {}
|
|
local last_error = type(settings.last_error) == "table" and settings.last_error or nil
|
|
local d = {
|
|
online = NetworkMgr:isConnected(),
|
|
matched = summary.matched or 0,
|
|
unmatched = summary.unmatched or 0,
|
|
waiting = (outbox.count or 0) + (coordinator.pending_count or 0),
|
|
synced = last_sync.event ~= "connection_test" and MenuUI.ago(last_sync.at) or nil,
|
|
last_message = last_sync.event ~= "connection_test" and last_sync.message or nil,
|
|
user = settings.username or "",
|
|
host = MenuUI.serverHost(settings.server_url) or "",
|
|
activity = Activity.list(settings),
|
|
}
|
|
-- A failure newer than the last success is the verdict until it clears.
|
|
if last_error and last_error.event ~= "connection_test"
|
|
and (tonumber(last_error.at) or 0) > (last_sync.event ~= "connection_test" and tonumber(last_sync.at) or 0) then
|
|
d.error = { message = last_error.message, when = MenuUI.ago(last_error.at) }
|
|
end
|
|
return d
|
|
end
|
|
|
|
local function hero(d, w, sync)
|
|
local pad = px(18)
|
|
local iw = w - 2 * pad
|
|
local glyph, title, sub, inverted
|
|
if sync and sync.running then
|
|
glyph, inverted = "refresh-cw", true
|
|
title = sync.recheck and _("Rechecking books…") or _("Syncing…")
|
|
sub = sync.text
|
|
elseif not d.online then
|
|
glyph, inverted = "wifi-off", true
|
|
title = d.waiting > 0 and T(_("Offline: %1 changes waiting"), d.waiting) or _("Offline")
|
|
sub = _("Kept on this device and sent when Wi-Fi is back")
|
|
elseif d.error then
|
|
glyph, inverted = "triangle-alert", true
|
|
title = _("The last sync did not finish")
|
|
sub = d.error.when and T(_("%1, %2"), d.error.message or _("unknown error"), d.error.when) or d.error.message
|
|
elseif d.waiting > 0 then
|
|
glyph, inverted = "clock", true
|
|
title = T(_("%1 changes waiting to send"), d.waiting)
|
|
sub = _("They go out with the next sync")
|
|
elseif not d.synced then
|
|
glyph, inverted = "refresh-cw", true
|
|
title = _("Not synced yet")
|
|
sub = _("Sync now to match the books on this device with your library")
|
|
else
|
|
glyph = "check"
|
|
title = _("Everything is synced")
|
|
sub = d.last_message and T(_("Last sync %1, %2"), d.synced, d.last_message) or T(_("Last sync %1"), d.synced)
|
|
end
|
|
local disc = inverted and BoardUI.circle(px(52), { border = math.max(2, px(2)), bg = INK.white }, icon(glyph, px(26), { sw = 2.75 }))
|
|
or BoardUI.circle(px(52), { bg = INK.black }, icon(glyph, px(26), { col = "white", sw = 2.75 }))
|
|
local tw = iw - px(52) - px(16)
|
|
return MenuUI.card(w, {
|
|
hstack({ w = iw, gap = px(16) }, {
|
|
disc,
|
|
vstack({ w = tw }, {
|
|
text(title, { s = 20, b = true, w = tw }),
|
|
text(sub or "", { s = 13, col = INK.g4, w = tw, lines = 2, data = true }),
|
|
}),
|
|
}),
|
|
}, { pad = 18, vpad = 18, strong = true })
|
|
end
|
|
|
|
local function statTile(w, value, label, action, label_s)
|
|
local pad = px(14)
|
|
local iw = w - 2 * pad
|
|
local head = action and hstack({ gap = px(6) }, { text(tostring(value), { s = 26, b = true }), icon("chevron-right", px(16), { sw = 2.5 }) })
|
|
or text(tostring(value), { s = 26, b = true })
|
|
local card = MenuUI.card(w, { vstack({ w = iw }, { head, space(nil, px(2)), text(label, { s = label_s or 13, col = INK.g4, lines = 2, w = iw }) }) }, { pad = 14, vpad = 12 })
|
|
return action and BoardUI.hit(card, action) or card
|
|
end
|
|
|
|
local function stats(d, w)
|
|
local gap = px(10)
|
|
local tw = math.floor((w - 2 * gap) / 3)
|
|
local tiles = {
|
|
{ tw, d.matched, _("books matched") },
|
|
{ w - 2 * tw - 2 * gap, d.unmatched, _("not in BookOrbit"), d.unmatched > 0 and { kind = "unmatched" } or nil },
|
|
{ tw, d.waiting, _("waiting to send") },
|
|
}
|
|
-- One caption size for the row, stepped down until every caption fits
|
|
-- its two lines.
|
|
local linesNeeded = require("bookorbit_text_pages").linesNeeded
|
|
local label_s = 13
|
|
while label_s > 11 do
|
|
local fits = true
|
|
for _i, t in ipairs(tiles) do
|
|
if linesNeeded(t[3], { s = label_s }, t[1] - 2 * px(14)) > 2 then fits = false end
|
|
end
|
|
if fits then break end
|
|
label_s = label_s - 1
|
|
end
|
|
local cells = {}
|
|
for _i, t in ipairs(tiles) do table.insert(cells, statTile(t[1], t[2], t[3], t[4], label_s)) end
|
|
return hstack({ w = w, gap = gap, valign = "top" }, cells)
|
|
end
|
|
|
|
-- As many recent events as fit in max_h.
|
|
local function activityList(d, w, max_h)
|
|
local iw = MenuUI.rowsIn(w)
|
|
local tw = px(78)
|
|
local rows = {}
|
|
local used = 0
|
|
for _i, a in ipairs(d.activity) do
|
|
local cw = iw - tw - px(10) - (a.warn and px(30) or 0)
|
|
local r = hstack({ w = iw, gap = px(10) }, {
|
|
text(MenuUI.ago(a.at) or "", { s = 13, col = INK.g4, w = tw }),
|
|
vstack({ w = cw, pad = { px(8), 0, px(8), 0 } }, {
|
|
text(a.text, { s = 15, w = cw, lines = 2, data = true }),
|
|
a.sub and text(a.sub, { s = 13, col = INK.g4, w = cw, lines = 2, data = true }) or false,
|
|
}),
|
|
a.warn and icon("triangle-alert", px(20), { sw = 2 }) or false,
|
|
})
|
|
local h = math.max(px(60), r:getSize().h)
|
|
r = hstack({ w = iw, h = h }, { r, flex(0) })
|
|
r._no_icon = true
|
|
if max_h and used + h + MenuUI.hair() > max_h and #rows > 0 then break end
|
|
used = used + h + MenuUI.hair()
|
|
table.insert(rows, r)
|
|
end
|
|
if #rows == 0 then
|
|
local empty = hstack({ w = iw, h = px(60) }, { text(_("Sync events will show here."), { s = 14, col = INK.g4, w = iw }) })
|
|
empty._no_icon = true
|
|
table.insert(rows, empty)
|
|
end
|
|
return MenuUI.group(w, rows, { inset = 0 })
|
|
end
|
|
|
|
-- Side by side, or one above the other in a narrow column. While a sync
|
|
-- runs, the first one stops it; each says how its last run went.
|
|
local function actions(d, w, stacked, sync, test)
|
|
local half = stacked and w or math.floor((w - px(10)) / 2)
|
|
local first
|
|
if sync and sync.running then
|
|
first = MenuUI.pill(_("Stop syncing"), { kind = "outline", action = { kind = "sync-stop" }, w = half })
|
|
elseif sync and sync.done and not sync.recheck then
|
|
first = MenuUI.pill(_("Synced"), { icon = "check", action = { kind = "sync" }, w = half })
|
|
else
|
|
first = MenuUI.pill(d.online and _("Sync now") or _("Connect and sync"), { icon = d.online and "refresh-cw" or "wifi", action = { kind = d.online and "sync" or "connect" }, w = half })
|
|
end
|
|
local second_w = stacked and w or (w - half - px(10))
|
|
local second
|
|
if test and test.running then
|
|
second = MenuUI.pill(_("Testing…"), { kind = "outline", action = { kind = "noop" }, w = second_w })
|
|
elseif test and test.ok then
|
|
second = MenuUI.pill(_("Connection works"), { kind = "outline", icon = "check", action = { kind = "test" }, w = second_w })
|
|
elseif test and test.err then
|
|
second = MenuUI.pill(_("Connection failed"), { kind = "outline", icon = "triangle-alert", action = { kind = "test" }, w = second_w })
|
|
else
|
|
second = MenuUI.pill(_("Test connection"), { kind = "outline", action = { kind = "test" }, w = second_w })
|
|
end
|
|
if stacked then return vstack({ w = w }, { first, space(nil, px(10)), second }) end
|
|
return hstack({ w = w, gap = px(10) }, { first, second })
|
|
end
|
|
|
|
local function fixes(w, compact, sync)
|
|
local iw = MenuUI.rowsIn(w)
|
|
local recheck_sub = not compact and _("Matches every book on this device again") or nil
|
|
if sync and sync.recheck then
|
|
if sync.running then recheck_sub = sync.text
|
|
elseif sync.done then recheck_sub = _("Done: every book was checked again")
|
|
elseif sync.err then recheck_sub = T(_("Could not recheck: %1"), sync.err) end
|
|
end
|
|
return MenuUI.group(w, {
|
|
MenuUI.row(iw, { icon = "list-checks", label = _("Recheck all books"), sub = recheck_sub, sub_data = sync and sync.running,
|
|
chevron = not (sync and sync.running), action = (sync and sync.running) and { kind = "noop" } or { kind = "recheck" } }),
|
|
MenuUI.row(iw, { icon = "file-text", label = _("Technical details"), sub = not compact and _("Everything the sync knows, for bug reports") or nil, chevron = true, action = { kind = "diagnostics" } }),
|
|
})
|
|
end
|
|
|
|
local function build(page, sw, sh)
|
|
local d = page.status_data
|
|
local mx = MenuUI.mx()
|
|
local top, bottom = px(6), px(12)
|
|
local w = sw - 2 * mx
|
|
local subtitle = not page.plugin:isLoggedIn() and _("Not signed in")
|
|
or d.host == "" and d.user or T(_("%1 on %2"), d.user, d.host)
|
|
local header = MenuUI.pageHeader(w, _("Sync status"), { sub = subtitle, sub_data = true })
|
|
if MenuUI.landscape() then
|
|
local gutter = px(24)
|
|
local lw = math.floor((w - gutter) / 2)
|
|
local rw = w - lw - gutter
|
|
local h_hero = hero(d, w, page.sync)
|
|
local room = sh - top - bottom - header:getSize().h - px(8) - h_hero:getSize().h - px(12)
|
|
-- The first arrangement whose left column fits a short screen with
|
|
-- nothing cut: the buttons stacked, then side by side, then with the
|
|
-- fixes moved under the recent activity, stacked and side by side.
|
|
local function left(stacked, with_fixes)
|
|
return vstack({ w = lw }, {
|
|
stats(d, lw), space(nil, px(10)), actions(d, lw, stacked, page.sync, page.test),
|
|
with_fixes and space(nil, px(10)) or false, with_fixes and fixes(lw, true, page.sync) or false,
|
|
})
|
|
end
|
|
local choice
|
|
for _i, v in ipairs({ { true, true }, { false, true }, { true, false }, { false, false } }) do
|
|
local clean, probe = BoardUI.fits(function() return BoardUI.collectHits(function() return left(v[1], v[2]) end) end)
|
|
local h = probe and probe.getSize and probe:getSize().h or math.huge
|
|
if probe and probe.free then probe:free() end
|
|
if clean and h <= room then choice = v; break end
|
|
end
|
|
choice = choice or { true, false }
|
|
local right_fixes = not choice[2] and fixes(rw, true, page.sync) or nil
|
|
local list_room = room - BoardMetrics.lh1(13) - px(8) - (right_fixes and (right_fixes:getSize().h + px(10)) or 0)
|
|
local right = vstack({ w = rw }, {
|
|
MenuUI.label(rw, _("Recent activity")), space(nil, px(8)), activityList(d, rw, list_room),
|
|
right_fixes and space(nil, px(10)) or false, right_fixes or false,
|
|
})
|
|
return vstack({ w = sw, h = sh, pad = { top, mx, bottom, mx } }, {
|
|
header, space(nil, px(8)), h_hero, space(nil, px(12)),
|
|
hstack({ w = w, gap = gutter, valign = "top" }, { left(choice[1], choice[2]), right }),
|
|
})
|
|
end
|
|
local fixed = { header, space(nil, px(8)), hero(d, w, page.sync), space(nil, px(10)), stats(d, w), space(nil, px(16)) }
|
|
local tail = { space(nil, px(16)), actions(d, w, false, page.sync, page.test), space(nil, px(16)), fixes(w, sh / BoardMetrics.get().dp < 1000, page.sync) }
|
|
local used = 0
|
|
for _i, item in ipairs(fixed) do used = used + item:getSize().h end
|
|
for _i, item in ipairs(tail) do used = used + item:getSize().h end
|
|
local avail = sh - top - bottom - used - BoardMetrics.lh1(13) - px(8)
|
|
local items = fixed
|
|
table.insert(items, MenuUI.section(w, _("Recent activity"), activityList(d, w, avail)))
|
|
for _i, item in ipairs(tail) do table.insert(items, item) end
|
|
return vstack({ w = sw, h = sh, pad = { top, mx, bottom, mx } }, items)
|
|
end
|
|
|
|
local function onAction(page, action)
|
|
local plugin = page.plugin
|
|
local kind = action.kind
|
|
-- The page stays open for all three: its verdict follows the run.
|
|
local refresh = function(reload)
|
|
if reload then page.status_data = Status.data(plugin) end
|
|
page:refresh()
|
|
end
|
|
if kind == "sync" or kind == "connect" then
|
|
local start = function() plugin:startPageSync(page, refresh) end
|
|
if kind == "connect" then NetworkMgr:runWhenConnected(start) else start() end
|
|
elseif kind == "sync-stop" then
|
|
plugin:confirmStopSync()
|
|
elseif kind == "test" then
|
|
plugin:startPageTest(page, refresh)
|
|
elseif kind == "recheck" then
|
|
plugin:startPageSync(page, refresh, { full_recheck = true })
|
|
elseif kind == "diagnostics" then
|
|
plugin:showDiagnostics()
|
|
elseif kind == "unmatched" then
|
|
require("bookorbit_dialog_ui").notice{
|
|
title = _("Books not in BookOrbit"),
|
|
text = _("Some books on this device are not in your BookOrbit library: files copied by hand, or edited since they were downloaded. Recheck all books to try matching them again."),
|
|
}
|
|
end
|
|
return true
|
|
end
|
|
|
|
function Status.open(plugin)
|
|
local page = MenuPage:new{
|
|
name = "bookorbit_sync_status",
|
|
build = build,
|
|
on_action = onAction,
|
|
plugin = plugin,
|
|
status_data = Status.data(plugin),
|
|
}
|
|
UIManager:show(page, "ui")
|
|
return page
|
|
end
|
|
|
|
return Status
|