Replace HistoryChart by new Kotlin implementation

This commit is contained in:
2021-01-01 09:36:17 -06:00
parent 354c930d85
commit 93a2ec3186
44 changed files with 366 additions and 799 deletions

View File

@@ -34,6 +34,7 @@ interface Canvas {
fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double)
fun drawText(text: String, x: Double, y: Double)
fun fillRect(x: Double, y: Double, width: Double, height: Double)
fun fillRoundRect(x: Double, y: Double, width: Double, height: Double, cornerRadius: Double)
fun drawRect(x: Double, y: Double, width: Double, height: Double)
fun getHeight(): Double
fun getWidth(): Double

View File

@@ -19,8 +19,6 @@
package org.isoron.platform.gui
data class PaletteColor(val index: Int)
data class Color(
val red: Double,
val green: Double,
@@ -48,4 +46,11 @@ data class Color(
alpha * (1 - weight) + other.alpha * weight
)
}
fun contrast(other: Color): Double {
val l1 = this.luminosity
val l2 = other.luminosity
val relativeLuminosity = (l1 + 0.05) / (l2 + 0.05)
return if (relativeLuminosity >= 1) relativeLuminosity else 1 / relativeLuminosity
}
}

View File

@@ -30,6 +30,7 @@ import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
import java.awt.RenderingHints.VALUE_FRACTIONALMETRICS_ON
import java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON
import java.awt.font.FontRenderContext
import java.awt.geom.RoundRectangle2D
import java.awt.image.BufferedImage
import kotlin.math.roundToInt
@@ -115,6 +116,25 @@ class JavaCanvas(
g2d.fillRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}
override fun fillRoundRect(
x: Double,
y: Double,
width: Double,
height: Double,
cornerRadius: Double
) {
g2d.fill(
RoundRectangle2D.Double(
toPixel(x).toDouble(),
toPixel(y).toDouble(),
toPixel(width).toDouble(),
toPixel(height).toDouble(),
toPixel(cornerRadius).toDouble(),
toPixel(cornerRadius).toDouble(),
)
)
}
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
g2d.drawRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}

View File

@@ -96,6 +96,7 @@ class ShowHabitPresenter {
habit = habit,
firstWeekday = preferences.firstWeekday,
isSkipEnabled = preferences.isSkipEnabled,
theme = theme,
),
bar = BarCardPresenter().present(
habit = habit,

View File

@@ -19,16 +19,24 @@
package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.ui.views.HistoryChart
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
import kotlin.math.max
data class HistoryCardViewModel(
val color: PaletteColor,
val entries: IntArray,
val firstWeekday: Int,
val isNumerical: Boolean,
val isSkipEnabled: Boolean,
val series: List<HistoryChart.Square>,
val theme: Theme,
val today: LocalDate,
)
class HistoryCardPresenter {
@@ -36,18 +44,37 @@ class HistoryCardPresenter {
habit: Habit,
firstWeekday: Int,
isSkipEnabled: Boolean,
theme: Theme,
): HistoryCardViewModel {
val today = DateUtils.getTodayWithOffset()
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
val entries =
habit.computedEntries.getByInterval(oldest, today).map { it.value }.toIntArray()
val entries = habit.computedEntries.getByInterval(oldest, today)
val series = if (habit.isNumerical) {
entries.map {
Entry(it.timestamp, max(0, it.value))
}.map {
when (it.value) {
0 -> HistoryChart.Square.OFF
else -> HistoryChart.Square.ON
}
}
} else {
entries.map {
when (it.value) {
YES_MANUAL -> HistoryChart.Square.ON
YES_AUTO -> HistoryChart.Square.DIMMED
SKIP -> HistoryChart.Square.HATCHED
else -> HistoryChart.Square.OFF
}
}
}
return HistoryCardViewModel(
entries = entries,
color = habit.color,
firstWeekday = firstWeekday,
isNumerical = habit.isNumerical,
isSkipEnabled = isSkipEnabled,
today = today.toLocalDate(),
theme = theme,
series = series,
)
}
}

View File

@@ -21,53 +21,74 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.DataView
import org.isoron.platform.gui.TextAlign
import org.isoron.platform.gui.View
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
import org.isoron.uhabits.core.models.PaletteColor
import kotlin.math.floor
import kotlin.math.round
class CalendarChart(
class HistoryChart(
var today: LocalDate,
var color: Color,
var paletteColor: PaletteColor,
var theme: Theme,
var dateFormatter: LocalDateFormatter
) : View {
) : DataView {
var padding = 5.0
var backgroundColor = Color(0xFFFFFF)
enum class Square {
ON,
OFF,
DIMMED,
HATCHED,
}
// Data
var series = listOf<Square>()
// Style
var padding = 0.0
var squareSpacing = 1.0
var series = listOf<Double>()
var scrollPosition = 0
override var dataOffset = 0
private var squareSize = 0.0
var lastPrintedMonth = ""
var lastPrintedYear = ""
override val dataColumnWidth: Double
get() = squareSpacing + squareSize
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
canvas.setColor(backgroundColor)
canvas.setColor(theme.cardBackgroundColor)
canvas.fillRect(0.0, 0.0, width, height)
squareSize = round((height - 2 * padding) / 8.0)
canvas.setFontSize(height * 0.06)
val nColumns = floor((width - 2 * padding) / squareSize).toInt() - 2
val todayWeekday = today.dayOfWeek
val topLeftOffset = (nColumns - 1 + scrollPosition) * 7 + todayWeekday.index
val topLeftOffset = (nColumns - 1 + dataOffset) * 7 + todayWeekday.index
val topLeftDate = today.minus(topLeftOffset)
lastPrintedYear = ""
lastPrintedMonth = ""
// Draw main columns
repeat(nColumns) { column ->
val topOffset = topLeftOffset - 7 * column
val topDate = topLeftDate.plus(7 * column)
drawColumn(canvas, column, topDate, topOffset)
}
// Draw week day names
canvas.setColor(theme.mediumContrastTextColor)
repeat(7) { row ->
val date = topLeftDate.plus(row)
canvas.setTextAlign(TextAlign.LEFT)
canvas.drawText(
dateFormatter.shortWeekdayName(date),
padding + nColumns * squareSize + padding,
padding + nColumns * squareSize + squareSpacing * 3,
padding + squareSize * (row + 1) + squareSize / 2
)
}
@@ -97,22 +118,29 @@ class CalendarChart(
}
private fun drawHeader(canvas: Canvas, column: Int, date: LocalDate) {
if (date.day >= 8) return
canvas.setColor(theme.mediumContrastTextColor)
if (date.month == 1) {
canvas.drawText(
date.year.toString(),
padding + column * squareSize + squareSize / 2,
padding + squareSize / 2
)
} else {
canvas.drawText(
dateFormatter.shortMonthName(date),
padding + column * squareSize + squareSize / 2,
padding + squareSize / 2
)
val monthText = dateFormatter.shortMonthName(date)
val yearText = date.year.toString()
val headerText: String
when {
monthText != lastPrintedMonth -> {
headerText = monthText
lastPrintedMonth = monthText
}
yearText != lastPrintedYear -> {
headerText = yearText
lastPrintedYear = headerText
}
else -> {
headerText = ""
}
}
canvas.setTextAlign(TextAlign.LEFT)
canvas.drawText(
headerText,
padding + column * squareSize,
padding + squareSize / 2
)
}
private fun drawSquare(
@@ -125,19 +153,46 @@ class CalendarChart(
offset: Int
) {
var value = if (offset >= series.size) 0.0 else series[offset]
value = round(value * 5.0) / 5.0
var squareColor = color.blendWith(backgroundColor, 1 - value)
var textColor = backgroundColor
if (value == 0.0) squareColor = theme.lowContrastTextColor
if (squareColor.luminosity > 0.8)
textColor = squareColor.blendWith(theme.highContrastTextColor, 0.5)
val value = if (offset >= series.size) Square.OFF else series[offset]
val squareColor: Color
val color = theme.color(paletteColor.paletteIndex)
when (value) {
Square.ON -> {
squareColor = color
}
Square.OFF -> {
squareColor = theme.lowContrastTextColor
}
Square.DIMMED, Square.HATCHED -> {
squareColor = color.blendWith(theme.cardBackgroundColor, 0.5)
}
}
canvas.setColor(squareColor)
canvas.fillRect(x, y, width, height)
canvas.fillRoundRect(x, y, width, height, width * 0.15)
if (value == Square.HATCHED) {
canvas.setStrokeWidth(0.75)
canvas.setColor(theme.cardBackgroundColor)
var k = width / 10
repeat(5) {
canvas.drawLine(x + k, y, x, y + k)
canvas.drawLine(
x + width - k,
y + height,
x + width,
y + height - k
)
k += width / 5
}
}
val c1 = squareColor.contrast(theme.cardBackgroundColor)
val c2 = squareColor.contrast(theme.mediumContrastTextColor)
val textColor = if (c1 > c2) theme.cardBackgroundColor else theme.mediumContrastTextColor
canvas.setColor(textColor)
canvas.setTextAlign(TextAlign.CENTER)
canvas.drawText(date.day.toString(), x + width / 2, y + width / 2)
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.components
import kotlinx.coroutines.runBlocking
import org.isoron.platform.gui.assertRenders
import org.isoron.platform.time.JavaLocalDateFormatter
import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.ui.views.DarkTheme
import org.isoron.uhabits.core.ui.views.HistoryChart
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.DIMMED
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.HATCHED
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.OFF
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.ON
import org.isoron.uhabits.core.ui.views.LightTheme
import org.junit.Test
import java.util.Locale
class HistoryChartTest {
val base = "views/HistoryChart"
val fmt = JavaLocalDateFormatter(Locale.US)
val theme = LightTheme()
val view = HistoryChart(
LocalDate(2015, 1, 25),
PaletteColor(7),
theme,
fmt,
).apply {
series = listOf(
2, // today
2, 1, 2, 1, 2, 1, 2,
2, 3, 3, 3, 3, 1, 2,
2, 1, 2, 1, 2, 2, 1,
1, 1, 1, 1, 2, 2, 2,
1, 3, 3, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 1, 1, 1,
2, 2, 2, 3, 3, 3, 1,
1, 2, 1, 2, 1, 1, 2,
1, 2, 1, 1, 1, 1, 2,
2, 2, 2, 2, 2, 1, 1,
1, 1, 2, 2, 1, 2, 1,
1, 1, 1, 1, 2, 2, 2,
).map {
when (it) {
3 -> HATCHED
2 -> ON
1 -> DIMMED
else -> OFF
}
}
}
// TODO: Label overflow
// TODO: Transparent
// TODO: onClick
// TODO: HistoryEditorDialog
// TODO: Remove excessive padding on widgets
// TODO: First day of the week
@Test
fun testDraw() = runBlocking {
assertRenders(400, 200, "$base/base.png", view)
}
@Test
fun testDrawDifferentSize() = runBlocking {
assertRenders(200, 200, "$base/small.png", view)
}
@Test
fun testDrawDarkTheme() = runBlocking {
view.theme = DarkTheme()
assertRenders(400, 200, "$base/dark.png", view)
}
@Test
fun testDrawOffset() = runBlocking {
view.dataOffset = 2
assertRenders(400, 200, "$base/scroll.png", view)
}
}