HistoryChart: Fix HistoryEditorDialog
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
BIN
android/uhabits-core/assets/test/views/HistoryChart/weekday.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
@@ -51,6 +51,7 @@ interface Canvas {
|
||||
fun fillCircle(centerX: Double, centerY: Double, radius: Double)
|
||||
fun setTextAlign(align: TextAlign)
|
||||
fun toImage(): Image
|
||||
fun measureText(test: String): Double
|
||||
|
||||
/**
|
||||
* Fills entire canvas with the current color.
|
||||
|
||||
@@ -36,13 +36,18 @@ import kotlin.math.roundToInt
|
||||
|
||||
class JavaCanvas(
|
||||
val image: BufferedImage,
|
||||
val pixelScale: Double = 2.0
|
||||
val pixelScale: Double = 2.0,
|
||||
) : Canvas {
|
||||
|
||||
override fun toImage(): Image {
|
||||
return JavaImage(image)
|
||||
}
|
||||
|
||||
override fun measureText(text: String): Double {
|
||||
val metrics = g2d.getFontMetrics(g2d.font)
|
||||
return toDp(metrics.stringWidth(text))
|
||||
}
|
||||
|
||||
private val frc = FontRenderContext(null, true, true)
|
||||
private var fontSize = 12.0
|
||||
private var font = Font.REGULAR
|
||||
@@ -121,7 +126,7 @@ class JavaCanvas(
|
||||
y: Double,
|
||||
width: Double,
|
||||
height: Double,
|
||||
cornerRadius: Double
|
||||
cornerRadius: Double,
|
||||
) {
|
||||
g2d.fill(
|
||||
RoundRectangle2D.Double(
|
||||
@@ -184,7 +189,7 @@ class JavaCanvas(
|
||||
centerY: Double,
|
||||
radius: Double,
|
||||
startAngle: Double,
|
||||
swipeAngle: Double
|
||||
swipeAngle: Double,
|
||||
) {
|
||||
|
||||
g2d.fillArc(
|
||||
|
||||
@@ -21,6 +21,8 @@ package org.isoron.platform.gui
|
||||
|
||||
interface View {
|
||||
fun draw(canvas: Canvas)
|
||||
fun onClick(x: Double, y: Double) {
|
||||
}
|
||||
}
|
||||
|
||||
interface DataView : View {
|
||||
|
||||
@@ -128,9 +128,14 @@ data class LocalDate(val daysSince2000: Int) {
|
||||
fun distanceTo(other: LocalDate): Int {
|
||||
return abs(daysSince2000 - other.daysSince2000)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "LocalDate($year-$month-$day)"
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalDateFormatter {
|
||||
fun shortWeekdayName(weekday: DayOfWeek): String
|
||||
fun shortWeekdayName(date: LocalDate): String
|
||||
fun shortMonthName(date: LocalDate): String
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ class JavaLocalDateFormatter(private val locale: Locale) : LocalDateFormatter {
|
||||
return if (longName.length <= 3) longName else shortName
|
||||
}
|
||||
|
||||
override fun shortWeekdayName(weekday: DayOfWeek): String {
|
||||
val cal = GregorianCalendar()
|
||||
cal.set(DAY_OF_WEEK, weekday.daysSinceSunday - 1)
|
||||
return shortWeekdayName(LocalDate(cal.get(YEAR), cal.get(MONTH) + 1, cal.get(DAY_OF_MONTH)))
|
||||
}
|
||||
|
||||
override fun shortWeekdayName(date: LocalDate): String {
|
||||
val cal = date.toGregorianCalendar()
|
||||
return cal.getDisplayName(DAY_OF_WEEK, SHORT, locale)
|
||||
|
||||
@@ -18,14 +18,15 @@
|
||||
*/
|
||||
package org.isoron.uhabits.core.ui.screens.habits.show
|
||||
|
||||
import org.isoron.platform.time.LocalDate
|
||||
import org.isoron.uhabits.core.commands.CommandRunner
|
||||
import org.isoron.uhabits.core.commands.CreateRepetitionCommand
|
||||
import org.isoron.uhabits.core.models.Entry
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.HabitList
|
||||
import org.isoron.uhabits.core.models.Timestamp
|
||||
import org.isoron.uhabits.core.preferences.Preferences
|
||||
import org.isoron.uhabits.core.ui.callbacks.OnToggleCheckmarkListener
|
||||
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
|
||||
import org.isoron.uhabits.core.ui.views.OnDateClickedListener
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ShowHabitBehavior(
|
||||
@@ -34,7 +35,7 @@ class ShowHabitBehavior(
|
||||
private val habit: Habit,
|
||||
private val screen: Screen,
|
||||
private val preferences: Preferences,
|
||||
) : OnToggleCheckmarkListener {
|
||||
) : OnDateClickedListener {
|
||||
|
||||
fun onScoreCardSpinnerPosition(position: Int) {
|
||||
preferences.scoreCardSpinnerPosition = position
|
||||
@@ -58,7 +59,9 @@ class ShowHabitBehavior(
|
||||
screen.showHistoryEditorDialog(this)
|
||||
}
|
||||
|
||||
override fun onToggleEntry(timestamp: Timestamp, value: Int) {
|
||||
override fun onDateClicked(date: LocalDate) {
|
||||
val timestamp = date.timestamp
|
||||
screen.touchFeedback()
|
||||
if (habit.isNumerical) {
|
||||
val entries = habit.computedEntries
|
||||
val oldValue = entries.get(timestamp).value
|
||||
@@ -74,12 +77,18 @@ class ShowHabitBehavior(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val currentValue = habit.computedEntries.get(timestamp).value
|
||||
val nextValue = if (preferences.isSkipEnabled) {
|
||||
Entry.nextToggleValueWithSkip(currentValue)
|
||||
} else {
|
||||
Entry.nextToggleValueWithoutSkip(currentValue)
|
||||
}
|
||||
commandRunner.run(
|
||||
CreateRepetitionCommand(
|
||||
habitList,
|
||||
habit,
|
||||
timestamp,
|
||||
value,
|
||||
nextValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -89,11 +98,12 @@ class ShowHabitBehavior(
|
||||
fun showNumberPicker(
|
||||
value: Double,
|
||||
unit: String,
|
||||
callback: ListHabitsBehavior.NumberPickerCallback
|
||||
callback: ListHabitsBehavior.NumberPickerCallback,
|
||||
)
|
||||
|
||||
fun updateWidgets()
|
||||
fun refresh()
|
||||
fun showHistoryEditorDialog(listener: OnToggleCheckmarkListener)
|
||||
fun showHistoryEditorDialog(listener: OnDateClickedListener)
|
||||
fun touchFeedback()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,23 @@ 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.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.round
|
||||
|
||||
fun interface OnDateClickedListener {
|
||||
fun onDateClicked(date: LocalDate)
|
||||
}
|
||||
|
||||
class HistoryChart(
|
||||
var today: LocalDate,
|
||||
var paletteColor: PaletteColor,
|
||||
var theme: Theme,
|
||||
var dateFormatter: LocalDateFormatter,
|
||||
var firstWeekday: DayOfWeek,
|
||||
var paletteColor: PaletteColor,
|
||||
var series: List<Square>,
|
||||
var theme: Theme,
|
||||
var today: LocalDate,
|
||||
var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { },
|
||||
var padding: Double = 0.0,
|
||||
) : DataView {
|
||||
|
||||
enum class Square {
|
||||
@@ -46,38 +54,58 @@ class HistoryChart(
|
||||
HATCHED,
|
||||
}
|
||||
|
||||
// Style
|
||||
var padding = 0.0
|
||||
var squareSpacing = 1.0
|
||||
override var dataOffset = 0
|
||||
private var squareSize = 0.0
|
||||
|
||||
var lastPrintedMonth = ""
|
||||
var lastPrintedYear = ""
|
||||
private var squareSize = 0.0
|
||||
private var width = 0.0
|
||||
private var height = 0.0
|
||||
private var nColumns = 0
|
||||
private var topLeftOffset = 0
|
||||
private var topLeftDate = LocalDate(2020, 1, 1)
|
||||
private var lastPrintedMonth = ""
|
||||
private var lastPrintedYear = ""
|
||||
private var headerOverflow = 0.0
|
||||
|
||||
override val dataColumnWidth: Double
|
||||
get() = squareSpacing + squareSize
|
||||
|
||||
override fun onClick(x: Double, y: Double) {
|
||||
if (width <= 0.0) throw IllegalStateException("onClick must be called after draw(canvas)")
|
||||
val col = ((x - padding) / squareSize).toInt()
|
||||
val row = ((y - padding) / squareSize).toInt()
|
||||
val offset = col * 7 + (row - 1)
|
||||
if (row == 0 || col == nColumns) return
|
||||
val clickedDate = topLeftDate.plus(offset)
|
||||
if (clickedDate.isNewerThan(today)) return
|
||||
onDateClickedListener.onDateClicked(clickedDate)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val width = canvas.getWidth()
|
||||
val height = canvas.getHeight()
|
||||
width = canvas.getWidth()
|
||||
height = canvas.getHeight()
|
||||
|
||||
canvas.setColor(theme.cardBackgroundColor)
|
||||
canvas.fill()
|
||||
|
||||
squareSize = round((height - 2 * padding) / 8.0)
|
||||
canvas.setFontSize(height * 0.06)
|
||||
canvas.setFontSize(min(14.0, height * 0.06))
|
||||
|
||||
val nColumns = floor((width - 2 * padding) / squareSize).toInt() - 2
|
||||
val weekdayColumnWidth = DayOfWeek.values().map { weekday ->
|
||||
canvas.measureText(dateFormatter.shortWeekdayName(weekday)) + squareSize * 0.15
|
||||
}.maxOrNull() ?: 0.0
|
||||
|
||||
nColumns = floor((width - 2 * padding - weekdayColumnWidth) / squareSize).toInt()
|
||||
val firstWeekdayOffset = (
|
||||
today.dayOfWeek.daysSinceSunday -
|
||||
firstWeekday.daysSinceSunday + 7
|
||||
) % 7
|
||||
val topLeftOffset = (nColumns - 1 + dataOffset) * 7 + firstWeekdayOffset
|
||||
val topLeftDate = today.minus(topLeftOffset)
|
||||
topLeftOffset = (nColumns - 1 + dataOffset) * 7 + firstWeekdayOffset
|
||||
topLeftDate = today.minus(topLeftOffset)
|
||||
|
||||
lastPrintedYear = ""
|
||||
lastPrintedMonth = ""
|
||||
headerOverflow = 0.0
|
||||
|
||||
// Draw main columns
|
||||
repeat(nColumns) { column ->
|
||||
@@ -93,7 +121,7 @@ class HistoryChart(
|
||||
canvas.setTextAlign(TextAlign.LEFT)
|
||||
canvas.drawText(
|
||||
dateFormatter.shortWeekdayName(date),
|
||||
padding + nColumns * squareSize + squareSpacing * 3,
|
||||
padding + nColumns * squareSize + squareSize * 0.15,
|
||||
padding + squareSize * (row + 1) + squareSize / 2
|
||||
)
|
||||
}
|
||||
@@ -143,9 +171,12 @@ class HistoryChart(
|
||||
canvas.setTextAlign(TextAlign.LEFT)
|
||||
canvas.drawText(
|
||||
headerText,
|
||||
padding + column * squareSize,
|
||||
headerOverflow + padding + column * squareSize,
|
||||
padding + squareSize / 2
|
||||
)
|
||||
|
||||
headerOverflow += canvas.measureText(headerText) + 0.1 * squareSize
|
||||
headerOverflow = max(0.0, headerOverflow - squareSize)
|
||||
}
|
||||
|
||||
private fun drawSquare(
|
||||
|
||||
@@ -33,19 +33,27 @@ 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.isoron.uhabits.core.ui.views.OnDateClickedListener
|
||||
import org.isoron.uhabits.core.ui.views.WidgetTheme
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.reset
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoMoreInteractions
|
||||
import java.util.Locale
|
||||
|
||||
class HistoryChartTest {
|
||||
val base = "views/HistoryChart"
|
||||
|
||||
val dateClickedListener = mock(OnDateClickedListener::class.java)
|
||||
|
||||
val view = HistoryChart(
|
||||
today = LocalDate(2015, 1, 25),
|
||||
paletteColor = PaletteColor(7),
|
||||
theme = LightTheme(),
|
||||
dateFormatter = JavaLocalDateFormatter(Locale.US),
|
||||
firstWeekday = SUNDAY,
|
||||
onDateClickedListener = dateClickedListener,
|
||||
series = listOf(
|
||||
2, // today
|
||||
2, 1, 2, 1, 2, 1, 2,
|
||||
@@ -71,16 +79,42 @@ class HistoryChartTest {
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: Label overflow
|
||||
// TODO: onClick
|
||||
// TODO: HistoryEditorDialog
|
||||
// TODO: Remove excessive padding on widgets
|
||||
|
||||
@Test
|
||||
fun testDraw() = runBlocking {
|
||||
assertRenders(400, 200, "$base/base.png", view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClick() = runBlocking {
|
||||
assertRenders(400, 200, "$base/base.png", view)
|
||||
|
||||
// Click top left date
|
||||
view.onClick(20.0, 46.0)
|
||||
verify(dateClickedListener).onDateClicked(LocalDate(2014, 10, 26))
|
||||
reset(dateClickedListener)
|
||||
view.onClick(2.0, 28.0)
|
||||
verify(dateClickedListener).onDateClicked(LocalDate(2014, 10, 26))
|
||||
reset(dateClickedListener)
|
||||
|
||||
// Click date in the middle
|
||||
view.onClick(163.0, 113.0)
|
||||
verify(dateClickedListener).onDateClicked(LocalDate(2014, 12, 10))
|
||||
reset(dateClickedListener)
|
||||
|
||||
// Click today
|
||||
view.onClick(336.0, 37.0)
|
||||
verify(dateClickedListener).onDateClicked(LocalDate(2015, 1, 25))
|
||||
reset(dateClickedListener)
|
||||
|
||||
// Click header
|
||||
view.onClick(160.0, 15.0)
|
||||
verifyNoMoreInteractions(dateClickedListener)
|
||||
|
||||
// Click right axis
|
||||
view.onClick(360.0, 60.0)
|
||||
verifyNoMoreInteractions(dateClickedListener)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDrawWeekDay() = runBlocking {
|
||||
view.firstWeekday = DayOfWeek.MONDAY
|
||||
|
||||