From 3ca46577bf1b0c6186204e4ba46b55f0727ab310 Mon Sep 17 00:00:00 2001 From: poire-z Date: Wed, 12 Feb 2020 23:05:18 +0100 Subject: [PATCH] Statistics: new Calendar view (#5854) Also fix RTL UI mirroring in OverlapGroup when overlap_offset are used. --- .../ui/widget/container/inputcontainer.lua | 2 +- frontend/ui/widget/overlapgroup.lua | 9 +- plugins/statistics.koplugin/calendarview.lua | 762 ++++++++++++++++++ plugins/statistics.koplugin/main.lua | 88 ++ 4 files changed, 858 insertions(+), 3 deletions(-) create mode 100644 plugins/statistics.koplugin/calendarview.lua diff --git a/frontend/ui/widget/container/inputcontainer.lua b/frontend/ui/widget/container/inputcontainer.lua index cd1d47aab..503cd61ef 100644 --- a/frontend/ui/widget/container/inputcontainer.lua +++ b/frontend/ui/widget/container/inputcontainer.lua @@ -272,7 +272,7 @@ function InputContainer:onInput(input, ignore_first_hold_release) local InputDialog = require("ui/widget/inputdialog") self.input_dialog = InputDialog:new{ title = input.title or "", - input = input.input, + input = input.input_func and input.input_func() or input.input, input_hint = input.hint_func and input.hint_func() or input.hint or "", input_type = input.type or "number", buttons = input.buttons or { diff --git a/frontend/ui/widget/overlapgroup.lua b/frontend/ui/widget/overlapgroup.lua index 1865215cc..5c46b6bdb 100644 --- a/frontend/ui/widget/overlapgroup.lua +++ b/frontend/ui/widget/overlapgroup.lua @@ -56,16 +56,21 @@ function OverlapGroup:paintTo(bb, x, y) local size = self:getSize() for i, wget in ipairs(self) do + local wget_size = wget:getSize() local overlap_align = wget.overlap_align if self._mirroredUI and self.allow_mirroring then + -- Checks in the same order as how they are checked below if overlap_align == "right" then overlap_align = "left" - elseif overlap_align ~= "center" then + elseif overlap_align == "center" then + overlap_align = "center" + elseif wget.overlap_offset then + wget.overlap_offset[1] = size.w - wget_size.w - wget.overlap_offset[1] + else overlap_align = "right" end -- see if something to do with wget.overlap_offset end - local wget_size = wget:getSize() if overlap_align == "right" then wget:paintTo(bb, x+size.w-wget_size.w, y) elseif overlap_align == "center" then diff --git a/plugins/statistics.koplugin/calendarview.lua b/plugins/statistics.koplugin/calendarview.lua new file mode 100644 index 000000000..cc44daf41 --- /dev/null +++ b/plugins/statistics.koplugin/calendarview.lua @@ -0,0 +1,762 @@ +local BD = require("ui/bidi") +local Blitbuffer = require("ffi/blitbuffer") +local BottomContainer = require("ui/widget/container/bottomcontainer") +local Button = require("ui/widget/button") +local CenterContainer = require("ui/widget/container/centercontainer") +local CloseButton = require("ui/widget/closebutton") +local Device = require("device") +local Font = require("ui/font") +local FrameContainer = require("ui/widget/container/framecontainer") +local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") +local HorizontalGroup = require("ui/widget/horizontalgroup") +local HorizontalSpan = require("ui/widget/horizontalspan") +local InputContainer = require("ui/widget/container/inputcontainer") +local KeyValuePage = require("ui/widget/keyvaluepage") +local LeftContainer = require("ui/widget/container/leftcontainer") +local Math = require("optmath") +local OverlapGroup = require("ui/widget/overlapgroup") +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 Widget = require("ui/widget/widget") +local Input = Device.input +local Screen = Device.screen +local _ = require("gettext") + +local CalendarTitle = VerticalGroup:new{ + calendar_view = nil, + title = "", + tface = Font:getFace("tfont"), + align = "left", +} + +function CalendarTitle:init() + self.close_button = CloseButton:new{ window = self } + local btn_width = self.close_button:getSize().w + self.text_w = TextWidget:new{ + text = self.title, + max_width = self.width - btn_width, + face = self.tface, + } + table.insert(self, OverlapGroup:new{ + dimen = { w = self.width }, + self.text_w, + self.close_button, + }) + table.insert(self, VerticalSpan:new{ width = Size.span.vertical_large }) +end + +function CalendarTitle:setTitle(title) + self.text_w:setText(title) +end + +function CalendarTitle:onClose() + self.calendar_view:onClose() + return true +end + + +local HistogramWidget = Widget:new{ + width = nil, + height = nil, + color = Blitbuffer.COLOR_BLACK, + nb_items = nil, + ratios = nil, -- table of 1...nb_items items, each with (0 <= value <= 1) +} + +function HistogramWidget:init() + self.dimen = Geom:new{w = self.width, h = self.height} + local item_width = math.floor(self.width / self.nb_items) + local nb_item_width_add1 = self.width - self.nb_items * item_width + local nb_item_width_add1_mod = math.floor(self.nb_items/nb_item_width_add1) + self.item_widths = {} + for n = 1, self.nb_items do + local w = item_width + if nb_item_width_add1 > 0 and n % nb_item_width_add1_mod == 0 then + w = w + 1 + nb_item_width_add1 = nb_item_width_add1 - 1 + end + table.insert(self.item_widths, w) + end + if BD.mirroredUILayout() then + self.do_mirror = true + end +end + +function HistogramWidget:paintTo(bb, x, y) + local i_x = 0 + for n = 1, self.nb_items do + if self.do_mirror then + n = self.nb_items - n + 1 + end + local i_w = self.item_widths[n] + local ratio = self.ratios and self.ratios[n] or 0 + local i_h = Math.round(ratio * self.height) + local i_y = self.height - i_h + if i_h > 0 then + bb:paintRect(x + i_x, y + i_y, i_w, i_h, self.color) + end + i_x = i_x + i_w + end +end + + +local CalendarDay = InputContainer:new{ + daynum = nil, + ratio_per_hour = nil, + filler = false, + width = nil, + height = nil, + border = 0, + is_future = false, + font_face = "xx_smallinfofont", + font_size = nil, + histo_height = nil, + nb_not_shown = nil, +} + +function CalendarDay:init() + self.dimen = Geom:new{w = self.width, h = self.height} + if self.filler then + return + end + if self.callback and Device:isTouchDevice() then + self.ges_events.Tap = { + GestureRange:new{ + ges = "tap", + range = self.dimen, + } + } + self.ges_events.Hold = { + GestureRange:new{ + ges = "hold", + range = self.dimen, + } + } + end + + self.daynum_w = TextWidget:new{ + text = " " .. tostring(self.daynum), + face = Font:getFace(self.font_face, self.font_size), + fgcolor = self.is_future and Blitbuffer.COLOR_GRAY or Blitbuffer.COLOR_BLACK, + padding = 0, + bold = true, + } + self.nb_not_shown_w = TextWidget:new{ + text = "", + face = Font:getFace(self.font_face, self.font_size - 1), + fgcolor = Blitbuffer.COLOR_DARK_GRAY, + overlap_align = "right", + } + local inner_w = self.width - 2*self.border + local inner_h = self.height - 2*self.border + if not self.histo_height then + self.histo_height = inner_h / 3 + end + self.histo_w = HistogramWidget:new{ + width = inner_w, + height = self.histo_height, + nb_items = 24, + ratios = self.ratio_per_hour, + } + self[1] = FrameContainer:new{ + padding = 0, + color = self.is_future and Blitbuffer.COLOR_GRAY or Blitbuffer.COLOR_BLACK, + bordersize = self.border, + width = self.width, + height = self.height, + OverlapGroup:new{ + self.daynum_w, + self.nb_not_shown_w, + BottomContainer:new{ + dimen = Geom:new{w = inner_w, h = inner_h}, + self.histo_w, + }, + } + } +end + +function CalendarDay:updateNbNotShown(nb) + self.nb_not_shown_w:setText(string.format("+ %d ", nb)) +end + +function CalendarDay:onTap() + if self.callback then + self.callback() + end + return true +end + +function CalendarDay:onHold() + return self:onTap() +end + + +local CalendarWeek = InputContainer:new{ + width = nil, + height = nil, + day_width = 0, + day_padding = 0, + day_border = 0, + nb_book_spans = 0, + span_height = nil, + font_size = 0, + font_face = "xx_smallinfofont", +} + +function CalendarWeek:init() + self.calday_widgets = {} + self.days_books = {} +end + +function CalendarWeek:addDay(calday_widget) + -- Add day widget to this week widget, and update the + -- list of books read this week for later showing book + -- spans, that may span multiple days. + table.insert(self.calday_widgets, calday_widget) + + local prev_day_num = #self.days_books + local prev_day_books = prev_day_num > 0 and self.days_books[#self.days_books] + local this_day_num = prev_day_num + 1 + local this_day_books = {} + table.insert(self.days_books, this_day_books) + + if not calday_widget.read_books then + calday_widget.read_books = {} + end + local nb_books_read = #calday_widget.read_books + if nb_books_read > self.nb_book_spans then + calday_widget:updateNbNotShown(nb_books_read - self.nb_book_spans) + end + for i=1, self.nb_book_spans do + if calday_widget.read_books[i] then + this_day_books[i] = calday_widget.read_books[i] -- brings id & title keys + this_day_books[i].span_days = 1 + this_day_books[i].start_day = this_day_num + this_day_books[i].fixed = false + else + this_day_books[i] = false + end + end + + if prev_day_books then + -- See if continuation from previous day, and re-order them if needed + for pn=1, #prev_day_books do + local prev_book = prev_day_books[pn] + if prev_book then + for tn=1, #this_day_books do + local this_book = this_day_books[tn] + if this_book and this_book.id == prev_book.id then + this_book.start_day = prev_book.start_day + this_book.fixed = true + this_book.span_days = prev_book.span_days + 1 + -- Update span_days in all previous books + for bk = 1, prev_book.span_days do + self.days_books[this_day_num-bk][pn].span_days = this_book.span_days + end + if tn ~= pn then -- swap it with the one at previous day position + this_day_books[tn], this_day_books[pn] = this_day_books[pn], this_day_books[tn] + end + break + end + end + end + end + end +end + +-- Set of { Font color, background color } +local SPAN_COLORS = { + { Blitbuffer.COLOR_BLACK, Blitbuffer.COLOR_WHITE }, + { Blitbuffer.COLOR_BLACK, Blitbuffer.COLOR_GRAY_E }, + { Blitbuffer.COLOR_BLACK, Blitbuffer.COLOR_LIGHT_GRAY }, + { Blitbuffer.COLOR_BLACK, Blitbuffer.COLOR_GRAY }, + { Blitbuffer.COLOR_WHITE, Blitbuffer.COLOR_WEB_GRAY }, + { Blitbuffer.COLOR_WHITE, Blitbuffer.COLOR_DARK_GRAY }, + { Blitbuffer.COLOR_WHITE, Blitbuffer.COLOR_DIM_GRAY }, + { Blitbuffer.COLOR_WHITE, Blitbuffer.COLOR_BLACK }, +} + +function CalendarWeek:update() + self.dimen = Geom:new{w = self.width, h = self.height} + self.day_container = HorizontalGroup:new{ + dimen = self.dimen:copy(), + } + for num, calday in ipairs(self.calday_widgets) do + table.insert(self.day_container, calday) + if num < #self.calday_widgets then + table.insert(self.day_container, HorizontalSpan:new{ width = self.day_padding, }) + end + end + + local overlaps = OverlapGroup:new{ + self.day_container, + } + -- Create and add BookSpans + local bspan_margin_h = Size.margin.tiny + self.day_border + local bspan_margin_v = Size.margin.tiny + local bspan_padding = Size.padding.tiny -- only used to compute the adequate font size + local bspan_border = Size.border.thin + local text_height = self.span_height - 2 * (bspan_margin_v + bspan_border + bspan_padding) + local inner_font_size = TextBoxWidget:getFontSizeToFitHeight(text_height, 1, 0) + for col, day_books in ipairs(self.days_books) do + for row, book in ipairs(day_books) do + if book and book.start_day == col then + local fgcolor, bgcolor = unpack(SPAN_COLORS[(book.id % #SPAN_COLORS)+1]) + local offset_x = (col-1) * (self.day_width + self.day_padding) + local offset_y = row * self.span_height -- 1st real row used by day num + offset_y = offset_y + Size.margin.small -- push it a bit down, over histogram + local width = book.span_days * self.day_width + self.day_padding * (book.span_days-1) + -- We use two FrameContainers, as (unlike HTML) a FrameContainer + -- draws the background color outside its borders, in the margins + local span_w = FrameContainer:new{ + width = width, + height = self.span_height, + margin = 0, + bordersize = 0, + padding_top = bspan_margin_v, + padding_bottom = bspan_margin_v, + padding_left = bspan_margin_h, + padding_right = bspan_margin_h, + overlap_offset = {offset_x, offset_y}, + FrameContainer:new{ + width = width - 2 * bspan_margin_h, + height = self.span_height - 2 * bspan_margin_v, + margin = 0, + padding = 0, + bordersize = bspan_border, + background = bgcolor, + CenterContainer:new{ + dimen = Geom:new{ + w = width - 2 * (bspan_margin_h + bspan_border), + h = self.span_height - 2 * (bspan_margin_v + bspan_border), + }, + TextWidget:new{ + text = BD.auto(book.title), + max_width = width - 2 * (bspan_margin_h + bspan_border + bspan_padding), + face = Font:getFace(self.font_face, inner_font_size), + padding = 0, + fgcolor = fgcolor, + } + } + } + } + table.insert(overlaps, span_w) + end + end + end + + self[1] = LeftContainer:new{ + dimen = self.dimen:copy(), + overlaps, + } +end + +-- Fetched from db, cached as local as it might be expensive +local MIN_MONTH = nil + +local CalendarView = InputContainer:new{ + reader_statistics = nil, + monthTranslation = nil, + shortDayOfWeekTranslation = nil, + startDayOfWeek = 2, -- 2 = Monday, 1-7 = Sunday-Saturday + nbFutureMonths = 0, -- nb of future months to allow browsing + nb_book_spans = 3, + title = "", + width = nil, + height = nil, + cur_month = nil, + weekdays = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } -- in Lua wday order + -- (These do not need translations: they are the key into + -- the provided self.shortDayOfWeekTranslation) +} + +function CalendarView:init() + self.dimen = Geom:new{ + w = self.width or Screen:getWidth(), + h = self.height or Screen:getHeight(), + } + if self.dimen.w == Screen:getWidth() and self.dimen.h == Screen:getHeight() then + self.covers_fullscreen = true -- hint for UIManager:_repaint() + end + + if Device:hasKeys() then + self.key_events = { + Close = { {"Back"}, doc = "close page" }, + NextMonth = {{Input.group.PgFwd}, doc = "next page"}, + PrevMonth = {{Input.group.PgBack}, doc = "prev page"}, + } + end + if Device:isTouchDevice() then + self.ges_events.Swipe = { + GestureRange:new{ + ges = "swipe", + range = self.dimen, + } + } + end + + local outer_padding = Size.padding.large + self.inner_padding = Size.padding.small + + -- 7 days in a week + self.day_width = math.floor((self.dimen.w - 2*outer_padding - 6*self.inner_padding) / 7) + -- Put back the possible 7px lost in rounding into outer_padding + outer_padding = math.floor((self.dimen.w - 7*self.day_width - 6*self.inner_padding) / 2) + self.content_width = self.dimen.w - 2*outer_padding + + if not MIN_MONTH then + local min_ts = self.reader_statistics:getFirstTimestamp() + if not min_ts then min_ts = os.time() end + MIN_MONTH = os.date("%Y-%m", min_ts) + end + self.min_month = MIN_MONTH + self.max_month = os.date("%Y-%m", os.time()) + if not self.cur_month then + self.cur_month = self.max_month + end + if self.nbFutureMonths > 0 then + local t = os.time({ + year = self.max_month:sub(1,4), + month = self.max_month:sub(6), + day = 15, + }) + t = t + 86400 * 30 * self.nbFutureMonths + self.max_month = os.date("%Y-%m", t) + end + + -- group for page info + local chevron_left = "resources/icons/appbar.chevron.left.png" + local chevron_right = "resources/icons/appbar.chevron.right.png" + local chevron_first = "resources/icons/appbar.chevron.first.png" + local chevron_last = "resources/icons/appbar.chevron.last.png" + if BD.mirroredUILayout() then + chevron_left, chevron_right = chevron_right, chevron_left + chevron_first, chevron_last = chevron_last, chevron_first + end + self.page_info_left_chev = Button:new{ + icon = chevron_left, + callback = function() self:prevMonth() end, + bordersize = 0, + show_parent = self, + } + self.page_info_right_chev = Button:new{ + icon = chevron_right, + callback = function() self:nextMonth() end, + bordersize = 0, + show_parent = self, + } + self.page_info_first_chev = Button:new{ + icon = chevron_first, + callback = function() self:goToMonth(self.min_month) end, + bordersize = 0, + show_parent = self, + } + self.page_info_last_chev = Button:new{ + icon = chevron_last, + callback = function() self:goToMonth(self.max_month) end, + bordersize = 0, + show_parent = self, + } + self.page_info_spacer = HorizontalSpan:new{ + width = Screen:scaleBySize(32), + } + + self.page_info_text = Button:new{ + text = "", + hold_input = { + title = _("Enter month"), + input_func = function() return self.cur_month end, + callback = function(input) + local year, month = input:match("^(%d%d%d%d)-(%d%d)$") + if year and month then + if tonumber(month) >= 1 and tonumber(month) <= 12 and tonumber(year) >= 1000 then + -- Allow seeing arbitrary year-month in the past or future by + -- not constraining to self.min_month/max_month. + -- (year >= 1000 to ensure %Y keeps returning 4 digits) + self:goToMonth(input) + return + end + end + local InfoMessage = require("ui/widget/infomessage") + UIManager:show(InfoMessage:new{ + text = _("Invalid year-month string (YYYY-MM)"), + }) + end, + }, + bordersize = 0, + margin = Screen:scaleBySize(20), + text_font_face = "pgfont", + text_font_bold = false, + } + self.page_info = HorizontalGroup:new{ + self.page_info_first_chev, + self.page_info_spacer, + self.page_info_left_chev, + self.page_info_text, + self.page_info_right_chev, + self.page_info_spacer, + self.page_info_last_chev, + } + + local footer = BottomContainer:new{ + dimen = Geom:new{ + w = self.content_width, + h = self.dimen.h, + }, + self.page_info, + } + + self.title_bar = CalendarTitle:new{ + title = self.title, + width = self.content_width, + height = Size.item.height_default, + calendar_view = self, + } + + -- week days names header + self.day_names = HorizontalGroup:new{} + for i = 0, 6 do + local dayname = TextWidget:new{ + text = self.shortDayOfWeekTranslation[self.weekdays[(self.startDayOfWeek-1+i)%7 + 1]], + face = Font:getFace("xx_smallinfofont"), + bold = true, + } + table.insert(self.day_names, FrameContainer:new{ + padding = 0, + bordersize = 0, + CenterContainer:new{ + dimen = Geom:new{ w = self.day_width, h = dayname:getSize().h }, + dayname, + } + }) + if i < 6 then + table.insert(self.day_names, HorizontalSpan:new{ width = self.inner_padding, }) + end + end + + -- At most 6 weeks in a month + local available_height = self.dimen.h - self.title_bar:getSize().h + - self.page_info:getSize().h - self.day_names:getSize().h + self.week_height = math.floor((available_height - 5*self.inner_padding) / 6) + self.day_border = Size.border.default + -- day num + nb_book_spans + histogram + self.span_height = math.ceil((self.week_height - 2*self.day_border) / (self.nb_book_spans + 2)) + self.span_font_size = TextBoxWidget:getFontSizeToFitHeight(self.span_height, 1, 0.3) + + self.main_content = VerticalGroup:new{} + self:_populateItems() + + local content = OverlapGroup:new{ + dimen = Geom:new{ + w = self.content_width, + h = self.dimen.h, + }, + allow_mirroring = false, + VerticalGroup:new{ + align = "left", + self.title_bar, + self.day_names, + self.main_content, + }, + footer, + } + -- assemble page + self[1] = FrameContainer:new{ + width = self.dimen.w, + height = self.dimen.h, + padding = outer_padding, + bordersize = 0, + background = Blitbuffer.COLOR_WHITE, + content + } +end + +function CalendarView:_populateItems() + self.page_info:resetLayout() + self.main_content:clear() + + -- See https://www.lua.org/pil/22.1.html for info about os.time() and os.date() + local month_start_ts = os.time({ + year = self.cur_month:sub(1,4), + month = self.cur_month:sub(6), + day = 1, + -- When hour is unspecified, Lua defaults to noon 12h00 + }) + -- Update title + local month_text = self.monthTranslation[os.date("%B", month_start_ts)] .. os.date(" %Y", month_start_ts) + self.title_bar:setTitle(month_text) + -- Update footer + self.page_info_text:setText(self.cur_month) + self.page_info_left_chev:enableDisable(self.cur_month > self.min_month) + self.page_info_right_chev:enableDisable(self.cur_month < self.max_month) + self.page_info_first_chev:enableDisable(self.cur_month > self.min_month) + self.page_info_last_chev:enableDisable(self.cur_month < self.max_month) + + local ratio_per_hour_by_day = self.reader_statistics:getReadingRatioPerHourByDay(self.cur_month) + local books_by_day = self.reader_statistics:getReadBookByDay(self.cur_month) + + table.insert(self.main_content, VerticalSpan:new{ width = self.inner_padding }) + self.weeks = {} + local today_s = os.date("%Y-%m-%d", os.time()) + local cur_ts = month_start_ts + local cur_date = os.date("*t", cur_ts) + local this_month = cur_date.month + local cur_week + while true do + cur_date = os.date("*t", cur_ts) + if cur_date.month ~= this_month then + break + end + if not cur_week or cur_date.wday == self.startDayOfWeek then + if cur_week then + table.insert(self.main_content, VerticalSpan:new{ width = self.inner_padding }) + end + cur_week = CalendarWeek:new{ + height = self.week_height, + width = self.content_width, + day_width = self.day_width, + day_padding = self.inner_padding, + day_border = self.day_border, + nb_book_spans = self.nb_book_spans, + span_height = self.span_height, + font_size = self.span_font_size, + show_parent = self, + } + table.insert(self.weeks, cur_week) + table.insert(self.main_content, cur_week) + if cur_date.wday ~= self.startDayOfWeek then + -- Add fake days to fill week + local day = self.startDayOfWeek + while day ~= cur_date.wday do + cur_week:addDay(CalendarDay:new{ + filler = true, + height = self.week_height, + width = self.day_width, + border = self.day_border, + show_parent = self, + }) + day = day + 1 + if day == 8 then + day = 1 + end + end + end + end + local day_s = os.date("%Y-%m-%d", cur_ts) + local day_text = string.format("%s (%s)", day_s, + self.shortDayOfWeekTranslation[self.weekdays[cur_date.wday]]) + local day_ts = os.time({ + year = cur_date.year, + month = cur_date.month, + day = cur_date.day, + hour = 0, + }) + cur_week:addDay(CalendarDay:new{ + histo_height = self.span_height, + font_size = self.span_font_size, + border = self.day_border, + is_future = day_s > today_s, + daynum = cur_date.day, + height = self.week_height, + width = self.day_width, + ratio_per_hour = ratio_per_hour_by_day[day_s], + read_books = books_by_day[day_s], + show_parent = self, + callback = function() + -- Just as ReaderStatistics:callbackDaily(), but without any window stacking + UIManager:show(KeyValuePage:new{ + title = day_text, + value_align = "right", + kv_pairs = self.reader_statistics:getBooksFromPeriod(day_ts, day_ts + 86400), + callback_return = function() end -- to just have that return button shown + }) + end + }) + cur_ts = cur_ts + 86400 -- add one day + end + for _, week in ipairs(self.weeks) do + week:update() + end + + UIManager:setDirty(self, function() + return "ui", self.dimen + end) +end + +function CalendarView:nextMonth() + local t = os.time({ + year = self.cur_month:sub(1,4), + month = self.cur_month:sub(6), + day = 15, + }) + t = t + 86400 * 30 -- 30 days later + local next_month = os.date("%Y-%m", t) + if next_month <= self.max_month then + self.cur_month = next_month + self:_populateItems() + end +end + +function CalendarView:prevMonth() + local t = os.time({ + year = self.cur_month:sub(1,4), + month = self.cur_month:sub(6), + day = 15, + }) + t = t - 86400 * 30 -- 30 days before + local prev_month = os.date("%Y-%m", t) + if prev_month >= self.min_month then + self.cur_month = prev_month + self:_populateItems() + end +end + +function CalendarView:goToMonth(month) + self.cur_month = month + self:_populateItems() +end + +function CalendarView:onNextMonth() + self:nextMonth() + return true +end + +function CalendarView:onPrevMonth() + self:prevMonth() + return true +end + +function CalendarView:onSwipe(arg, ges_ev) + local direction = BD.flipDirectionIfMirroredUILayout(ges_ev.direction) + if direction == "west" then + self:nextMonth() + return true + elseif direction == "east" then + self:prevMonth() + return true + elseif direction == "south" then + -- Allow easier closing with swipe down + self:onClose() + elseif direction == "north" then + -- no use for now + do end -- luacheck: ignore 541 + else -- diagonal swipe + -- trigger full refresh + UIManager:setDirty(nil, "full") + -- a long diagonal swipe may also be used for taking a screenshot, + -- so let it propagate + return false + end +end + +function CalendarView:onClose() + UIManager:close(self) + return true +end + +return CalendarView diff --git a/plugins/statistics.koplugin/main.lua b/plugins/statistics.koplugin/main.lua index f6d2ca3b9..3afefad09 100644 --- a/plugins/statistics.koplugin/main.lua +++ b/plugins/statistics.koplugin/main.lua @@ -770,6 +770,18 @@ function ReaderStatistics:addToMainMenu(menu_items) self:statMenu() end }, + { + text = _("Calendar view"), + keep_menu_open = true, + callback = function() + local CalendarView = require("calendarview") + UIManager:show(CalendarView:new{ + reader_statistics = self, + monthTranslation = monthTranslation, + shortDayOfWeekTranslation = shortDayOfWeekTranslation, + }) + end, + }, }, } end @@ -1790,4 +1802,80 @@ function ReaderStatistics:onReaderReady() self.view.footer:updateFooter() end +-- Used by calendarview.lua CalendarView +function ReaderStatistics:getFirstTimestamp() + local sql_stmt = [[ + SELECT min(start_time) + FROM page_stat + ]] + local conn = SQ3.open(db_location) + local first_ts = conn:rowexec(sql_stmt) + conn:close() + return first_ts and tonumber(first_ts) or nil +end + +function ReaderStatistics:getReadingRatioPerHourByDay(month) + local sql_stmt = [[ + SELECT + strftime('%Y-%m-%d', start_time, 'unixepoch', 'localtime') day, + strftime('%H', start_time, 'unixepoch', 'localtime') hour, + sum(period)/3600.0 ratio + FROM page_stat + WHERE strftime('%Y-%m', start_time, 'unixepoch', 'localtime') = ? + GROUP BY + strftime('%Y-%m-%d', start_time, 'unixepoch', 'localtime'), + strftime('%H', start_time, 'unixepoch', 'localtime') + ORDER BY day, hour + ]] + local conn = SQ3.open(db_location) + local stmt = conn:prepare(sql_stmt) + local res, nb = stmt:reset():bind(month):resultset("i") + stmt:close() + conn:close() + local per_day = {} + for i=1, nb do + local day, hour, ratio = res[1][i], res[2][i], res[3][i] + if not per_day[day] then + per_day[day] = {} + end + -- +1 as histogram starts counting at 1 + per_day[day][tonumber(hour)+1] = ratio + end + return per_day +end + +function ReaderStatistics:getReadBookByDay(month) + local sql_stmt = [[ + SELECT + strftime('%Y-%m-%d', start_time, 'unixepoch', 'localtime') day, + sum(period) duration, + id_book book_id, + book.title book_title + FROM page_stat + JOIN book on book.id = page_stat.id_book + WHERE strftime('%Y-%m', start_time, 'unixepoch', 'localtime') = ? + GROUP BY + strftime('%Y-%m-%d', start_time, 'unixepoch', 'localtime'), + id_book, + title + ORDER BY day, duration desc, book_id, book_title + ]] + local conn = SQ3.open(db_location) + local stmt = conn:prepare(sql_stmt) + local res, nb = stmt:reset():bind(month):resultset("i") + stmt:close() + conn:close() + local per_day = {} + for i=1, nb do + -- (We don't care about the duration, we just needed it + -- to have the books in decreasing duration order) + local day, duration, book_id, book_title = res[1][i], res[2][i], res[3][i], res[4][i] -- luacheck: no unused + if not per_day[day] then + per_day[day] = {} + end + table.insert(per_day[day], { id = tonumber(book_id), title = tostring(book_title) }) + end + return per_day +end + return ReaderStatistics