mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-07 09:38:52 -06:00
Replace HistoryChart by new Kotlin implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ class ShowHabitPresenter {
|
||||
habit = habit,
|
||||
firstWeekday = preferences.firstWeekday,
|
||||
isSkipEnabled = preferences.isSkipEnabled,
|
||||
theme = theme,
|
||||
),
|
||||
bar = BarCardPresenter().present(
|
||||
habit = habit,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user