Copy platform.{gui,io,time} from core; implement AndroidCanvas

pull/707/head
Alinson S. Xavier 5 years ago
parent 13826f4934
commit e97cdce467

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@ -0,0 +1,39 @@
/*
* Copyright (C) 2016-2020 Á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.platform.gui
import android.graphics.Bitmap
import org.isoron.uhabits.BaseViewTest
import org.junit.Test
class AndroidCanvasTest : BaseViewTest() {
@Test
fun testDrawTestImage() {
similarityCutoff = 0.0005
val bmp = Bitmap.createBitmap(1000, 800, Bitmap.Config.ARGB_8888)
val canvas = AndroidCanvas()
canvas.context = testContext
canvas.density = 2.0
canvas.innerCanvas = android.graphics.Canvas(bmp)
canvas.innerBitmap = bmp
canvas.drawTestImage()
assertRenders(bmp, "CanvasTest.png")
}
}

@ -47,11 +47,14 @@ public class BaseViewTest extends BaseAndroidTest
protected void assertRenders(View view, String expectedImagePath)
throws IOException
{
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
expectedImagePath = "views/" + expectedImagePath;
Bitmap actual = renderView(view);
if(actual == null) throw new IllegalStateException("actual is null");
assertRenders(actual, expectedImagePath);
}
protected void assertRenders(Bitmap actual, String expectedImagePath) throws IOException {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
expectedImagePath = "views/" + expectedImagePath;
try
{
Bitmap expected = getBitmapFromAssets(expectedImagePath);
@ -145,7 +148,7 @@ public class BaseViewTest extends BaseAndroidTest
}
}
distance /= (0xff * 16) * b1.getWidth() * b1.getHeight();
distance /= 255.0 * 16 * b1.getWidth() * b1.getHeight();
return distance;
}

@ -0,0 +1,169 @@
/*
* Copyright (C) 2016-2020 Á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.platform.gui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
class AndroidCanvas : Canvas {
lateinit var innerCanvas: android.graphics.Canvas
lateinit var context: Context
var innerBitmap: Bitmap? = null
var density = 1.0
var paint = Paint().apply {
isAntiAlias = true
}
var textPaint = TextPaint().apply {
isAntiAlias = true
}
var textBounds = Rect()
private fun Double.toDp() = (this * density).toFloat()
override fun setColor(color: Color) {
paint.color = color.toInt()
textPaint.color = color.toInt()
}
override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) {
innerCanvas.drawLine(
x1.toDp(),
y1.toDp(),
x2.toDp(),
y2.toDp(),
paint,
)
}
override fun drawText(text: String, x: Double, y: Double) {
textPaint.getTextBounds(text, 0, text.length, textBounds)
innerCanvas.drawText(
text,
x.toDp(),
y.toDp() - textBounds.exactCenterY(),
textPaint,
)
}
override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
paint.style = Paint.Style.FILL
rect(x, y, width, height)
}
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
paint.style = Paint.Style.STROKE
rect(x, y, width, height)
}
private fun rect(x: Double, y: Double, width: Double, height: Double) {
innerCanvas.drawRect(
x.toDp(),
y.toDp(),
(x + width).toDp(),
(y + height).toDp(),
paint,
)
}
override fun getHeight(): Double {
return innerCanvas.height / density
}
override fun getWidth(): Double {
return innerCanvas.width / density
}
override fun setFont(font: Font) {
textPaint.typeface = when (font) {
Font.REGULAR -> Typeface.DEFAULT
Font.BOLD -> Typeface.DEFAULT_BOLD
Font.FONT_AWESOME -> getFontAwesome(context)
}
}
override fun setFontSize(size: Double) {
textPaint.textSize = size.toDp() * 1.07f
}
override fun setStrokeWidth(size: Double) {
paint.strokeWidth = size.toDp()
}
override fun fillArc(
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
swipeAngle: Double,
) {
paint.style = Paint.Style.FILL
innerCanvas.drawArc(
(centerX - radius).toDp(),
(centerY - radius).toDp(),
(centerX + radius).toDp(),
(centerY + radius).toDp(),
-startAngle.toFloat(),
-swipeAngle.toFloat(),
true,
paint,
)
}
override fun fillCircle(
centerX: Double,
centerY: Double,
radius: Double,
) {
paint.style = Paint.Style.FILL
innerCanvas.drawCircle(centerX.toDp(), centerY.toDp(), radius.toDp(), paint)
}
override fun setTextAlign(align: TextAlign) {
textPaint.textAlign = when (align) {
TextAlign.LEFT -> Paint.Align.LEFT
TextAlign.CENTER -> Paint.Align.CENTER
TextAlign.RIGHT -> Paint.Align.RIGHT
}
}
override fun toImage(): Image {
val bmp = innerBitmap ?: throw UnsupportedOperationException()
return AndroidImage(bmp)
}
}
class AndroidCanvasTestView(context: Context, attrs: AttributeSet) : View(context, attrs) {
val canvas = AndroidCanvas()
override fun onDraw(canvas: android.graphics.Canvas) {
this.canvas.context = context
this.canvas.innerCanvas = canvas
this.canvas.density = resources.displayMetrics.density.toDouble()
this.canvas.drawTestImage()
}
}

@ -0,0 +1,52 @@
/*
* Copyright (C) 2016-2020 Á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.platform.gui
import android.graphics.Bitmap
import kotlin.math.roundToInt
class AndroidImage(private val bmp: Bitmap) : Image {
override val width: Int
get() = bmp.width
override val height: Int
get() = bmp.height
override fun getPixel(x: Int, y: Int): Color {
return Color(bmp.getPixel(x, y))
}
override fun setPixel(x: Int, y: Int, color: Color) {
bmp.setPixel(x, y, color.toInt())
}
override suspend fun export(path: String) {
TODO("Not yet implemented")
}
}
public fun Color.toInt(): Int {
return android.graphics.Color.argb(
(255 * this.alpha).roundToInt(),
(255 * this.red).roundToInt(),
(255 * this.green).roundToInt(),
(255 * this.blue).roundToInt(),
)
}

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2016-2020 Á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/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<org.isoron.platform.gui.AndroidCanvasTestView
android:layout_width="500dp"
android:layout_height="400dp" />
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@ -0,0 +1,2 @@
Hello World!
This is a resource.

@ -0,0 +1,97 @@
/*
* 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.platform.gui
enum class TextAlign {
LEFT, CENTER, RIGHT
}
enum class Font {
REGULAR,
BOLD,
FONT_AWESOME
}
interface Canvas {
fun setColor(color: Color)
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 drawRect(x: Double, y: Double, width: Double, height: Double)
fun getHeight(): Double
fun getWidth(): Double
fun setFont(font: Font)
fun setFontSize(size: Double)
fun setStrokeWidth(size: Double)
fun fillArc(
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
swipeAngle: Double
)
fun fillCircle(centerX: Double, centerY: Double, radius: Double)
fun setTextAlign(align: TextAlign)
fun toImage(): Image
fun drawTestImage() {
// Draw grey background
setColor(Color(0x303030))
fillRect(0.0, 0.0, 500.0, 400.0)
// Draw center rectangle
setColor(Color(0x606060))
setStrokeWidth(25.0)
drawRect(100.0, 100.0, 300.0, 200.0)
// Draw squares, circles and arcs
setColor(Color(0xFFFF00))
setStrokeWidth(1.0)
drawRect(0.0, 0.0, 100.0, 100.0)
fillCircle(50.0, 50.0, 30.0)
drawRect(0.0, 100.0, 100.0, 100.0)
fillArc(50.0, 150.0, 30.0, 90.0, 135.0)
drawRect(0.0, 200.0, 100.0, 100.0)
fillArc(50.0, 250.0, 30.0, 90.0, -135.0)
drawRect(0.0, 300.0, 100.0, 100.0)
fillArc(50.0, 350.0, 30.0, 45.0, 90.0)
// Draw two red crossing lines
setColor(Color(0xFF0000))
setStrokeWidth(2.0)
drawLine(0.0, 0.0, 500.0, 400.0)
drawLine(500.0, 0.0, 0.0, 400.0)
// Draw text
setFont(Font.BOLD)
setFontSize(50.0)
setColor(Color(0x00FF00))
setTextAlign(TextAlign.CENTER)
drawText("HELLO", 250.0, 100.0)
setTextAlign(TextAlign.RIGHT)
drawText("HELLO", 250.0, 150.0)
setTextAlign(TextAlign.LEFT)
drawText("HELLO", 250.0, 200.0)
// Draw FontAwesome icon
setFont(Font.FONT_AWESOME)
drawText(FontAwesome.CHECK, 250.0, 300.0)
}
}

@ -0,0 +1,51 @@
/*
* 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.platform.gui
data class PaletteColor(val index: Int)
data class Color(
val red: Double,
val green: Double,
val blue: Double,
val alpha: Double
) {
val luminosity: Double
get() {
return 0.21 * red + 0.72 * green + 0.07 * blue
}
constructor(rgb: Int) : this(
((rgb shr 16) and 0xFF) / 255.0,
((rgb shr 8) and 0xFF) / 255.0,
((rgb shr 0) and 0xFF) / 255.0,
1.0
)
fun blendWith(other: Color, weight: Double): Color {
return Color(
red * (1 - weight) + other.red * weight,
green * (1 - weight) + other.green * weight,
blue * (1 - weight) + other.blue * weight,
alpha * (1 - weight) + other.alpha * weight
)
}
}

@ -0,0 +1,24 @@
/*
* 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.platform.gui
interface Component {
fun draw(canvas: Canvas)
}

@ -0,0 +1,27 @@
/*
* 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.platform.gui
class FontAwesome {
companion object {
val CHECK = "\uf00c"
val TIMES = "\uf00d"
}
}

@ -0,0 +1,65 @@
/*
* 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.platform.gui
import kotlin.math.abs
import kotlin.math.min
interface Image {
val width: Int
val height: Int
fun getPixel(x: Int, y: Int): Color
fun setPixel(x: Int, y: Int, color: Color)
suspend fun export(path: String)
fun diff(other: Image) {
if (width != other.width) error("Width must match: $width !== ${other.width}")
if (height != other.height) error("Height must match: $height !== ${other.height}")
for (x in 0 until width) {
for (y in 0 until height) {
val p1 = getPixel(x, y)
var l = 1.0
for (dx in -2..2) {
if (x + dx < 0 || x + dx >= width) continue
for (dy in -2..2) {
if (y + dy < 0 || y + dy >= height) continue
val p2 = other.getPixel(x + dx, y + dy)
l = min(l, abs(p1.luminosity - p2.luminosity))
}
}
setPixel(x, y, Color(l, l, l, 1.0))
}
}
}
val averageLuminosity: Double
get() {
var luminosity = 0.0
for (x in 0 until width) {
for (y in 0 until height) {
luminosity += getPixel(x, y).luminosity
}
}
return luminosity / (width * height)
}
}

@ -0,0 +1,189 @@
/*
* 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.platform.gui
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.isoron.platform.io.JavaResourceFile
import java.awt.BasicStroke
import java.awt.RenderingHints.KEY_ANTIALIASING
import java.awt.RenderingHints.KEY_FRACTIONALMETRICS
import java.awt.RenderingHints.KEY_TEXT_ANTIALIASING
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.image.BufferedImage
import kotlin.math.roundToInt
class JavaCanvas(
val image: BufferedImage,
val pixelScale: Double = 2.0
) : Canvas {
override fun toImage(): Image {
return JavaImage(image)
}
private val frc = FontRenderContext(null, true, true)
private var fontSize = 12.0
private var font = Font.REGULAR
private var textAlign = TextAlign.CENTER
val widthPx = image.width
val heightPx = image.height
val g2d = image.createGraphics()
private val NOTO_REGULAR_FONT = createFont("fonts/NotoSans-Regular.ttf")
private val NOTO_BOLD_FONT = createFont("fonts/NotoSans-Bold.ttf")
private val FONT_AWESOME_FONT = createFont("fonts/FontAwesome.ttf")
init {
g2d.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
g2d.setRenderingHint(KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON)
g2d.setRenderingHint(KEY_FRACTIONALMETRICS, VALUE_FRACTIONALMETRICS_ON)
updateFont()
}
private fun toPixel(x: Double): Int {
return (pixelScale * x).toInt()
}
private fun toDp(x: Int): Double {
return x / pixelScale
}
override fun setColor(color: Color) {
g2d.color = java.awt.Color(
color.red.toFloat(),
color.green.toFloat(),
color.blue.toFloat(),
color.alpha.toFloat()
)
}
override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) {
g2d.drawLine(toPixel(x1), toPixel(y1), toPixel(x2), toPixel(y2))
}
override fun drawText(text: String, x: Double, y: Double) {
updateFont()
val bounds = g2d.font.getStringBounds(text, frc)
val bWidth = bounds.width.roundToInt()
val bHeight = bounds.height.roundToInt()
val bx = bounds.x.roundToInt()
val by = bounds.y.roundToInt()
if (textAlign == TextAlign.CENTER) {
g2d.drawString(
text,
toPixel(x) - bx - bWidth / 2,
toPixel(y) - by - bHeight / 2
)
} else if (textAlign == TextAlign.LEFT) {
g2d.drawString(
text,
toPixel(x) - bx,
toPixel(y) - by - bHeight / 2
)
} else {
g2d.drawString(
text,
toPixel(x) - bx - bWidth,
toPixel(y) - by - bHeight / 2
)
}
}
override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
g2d.fillRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
g2d.drawRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
}
override fun getHeight(): Double {
return toDp(heightPx)
}
override fun getWidth(): Double {
return toDp(widthPx)
}
override fun setFont(font: Font) {
this.font = font
updateFont()
}
override fun setFontSize(size: Double) {
fontSize = size
updateFont()
}
override fun setStrokeWidth(size: Double) {
g2d.stroke = BasicStroke((size * pixelScale).toFloat())
}
private fun updateFont() {
val size = (fontSize * pixelScale).toFloat()
g2d.font = when (font) {
Font.REGULAR -> NOTO_REGULAR_FONT.deriveFont(size)
Font.BOLD -> NOTO_BOLD_FONT.deriveFont(size)
Font.FONT_AWESOME -> FONT_AWESOME_FONT.deriveFont(size)
}
}
override fun fillCircle(centerX: Double, centerY: Double, radius: Double) {
g2d.fillOval(
toPixel(centerX - radius),
toPixel(centerY - radius),
toPixel(radius * 2),
toPixel(radius * 2)
)
}
override fun fillArc(
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
swipeAngle: Double
) {
g2d.fillArc(
toPixel(centerX - radius),
toPixel(centerY - radius),
toPixel(radius * 2),
toPixel(radius * 2),
startAngle.roundToInt(),
swipeAngle.roundToInt()
)
}
override fun setTextAlign(align: TextAlign) {
this.textAlign = align
}
private fun createFont(path: String) = runBlocking<java.awt.Font> {
val file = JavaFileOpener().openResourceFile(path) as JavaResourceFile
if (!file.exists()) throw RuntimeException("File not found: ${file.path}")
java.awt.Font.createFont(0, file.stream())
}
}

@ -0,0 +1,54 @@
/*
* 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.platform.gui
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
class JavaImage(val bufferedImage: BufferedImage) : Image {
override fun setPixel(x: Int, y: Int, color: Color) {
bufferedImage.setRGB(
x,
y,
java.awt.Color(
color.red.toFloat(),
color.green.toFloat(),
color.blue.toFloat()
).rgb
)
}
override suspend fun export(path: String) {
val file = File(path)
file.parentFile.mkdirs()
ImageIO.write(bufferedImage, "png", file)
}
override val width: Int
get() = bufferedImage.width
override val height: Int
get() = bufferedImage.height
override fun getPixel(x: Int, y: Int): Color {
return Color(bufferedImage.getRGB(x, y))
}
}

@ -0,0 +1,98 @@
/*
* 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.platform.io
import org.isoron.platform.gui.Image
interface FileOpener {
/**
* Opens a file which was shipped bundled with the application, such as a
* migration file.
*
* The path is relative to the assets folder. For example, to open
* assets/main/migrations/09.sql you should provide migrations/09.sql
* as the path.
*
* This function always succeed, even if the file does not exist.
*/
fun openResourceFile(path: String): ResourceFile
/**
* Opens a file which was not shipped with the application, such as
* databases and logs.
*
* The path is relative to the user folder. For example, if the application
* stores the user data at /home/user/.loop/ and you wish to open the file
* /home/user/.loop/crash.log, you should provide crash.log as the path.
*
* This function always succeed, even if the file does not exist.
*/
fun openUserFile(path: String): UserFile
}
/**
* Represents a file that was created after the application was installed, as a
* result of some user action, such as databases and logs.
*/
interface UserFile {
/**
* Deletes the user file. If the file does not exist, nothing happens.
*/
suspend fun delete()
/**
* Returns true if the file exists.
*/
suspend fun exists(): Boolean
/**
* Returns the lines of the file. If the file does not exist, throws an
* exception.
*/
suspend fun lines(): List<String>
}
/**
* Represents a file that was shipped with the application, such as migration
* files or database templates.
*/
interface ResourceFile {
/**
* Copies the resource file to the specified user file. If the user file
* already exists, it is replaced. If not, a new file is created.
*/
suspend fun copyTo(dest: UserFile)
/**
* Returns the lines of the resource file. If the file does not exist,
* throws an exception.
*/
suspend fun lines(): List<String>
/**
* Returns true if the file exists.
*/
suspend fun exists(): Boolean
/**
* Loads resource file as an image.
*/
suspend fun toImage(): Image
}

@ -0,0 +1,89 @@
/*
* 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.platform.io
import org.isoron.platform.gui.Image
import org.isoron.platform.gui.JavaImage
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import javax.imageio.ImageIO
@Suppress("NewApi")
class JavaResourceFile(val path: String) : ResourceFile {
private val javaPath: Path
get() {
val mainPath = Paths.get("assets/main/$path")
val testPath = Paths.get("assets/test/$path")
if (Files.exists(mainPath)) return mainPath
else return testPath
}
override suspend fun exists(): Boolean {
return Files.exists(javaPath)
}
override suspend fun lines(): List<String> {
return Files.readAllLines(javaPath)
}
override suspend fun copyTo(dest: UserFile) {
if (dest.exists()) dest.delete()
val destPath = (dest as JavaUserFile).path
destPath.toFile().parentFile?.mkdirs()
Files.copy(javaPath, destPath)
}
fun stream(): InputStream {
return Files.newInputStream(javaPath)
}
override suspend fun toImage(): Image {
return JavaImage(ImageIO.read(stream()))
}
}
@Suppress("NewApi")
class JavaUserFile(val path: Path) : UserFile {
override suspend fun lines(): List<String> {
return Files.readAllLines(path)
}
override suspend fun exists(): Boolean {
return Files.exists(path)
}
override suspend fun delete() {
Files.delete(path)
}
}
@Suppress("NewApi")
class JavaFileOpener : FileOpener {
override fun openUserFile(path: String): UserFile {
val path = Paths.get("/tmp/$path")
return JavaUserFile(path)
}
override fun openResourceFile(path: String): ResourceFile {
return JavaResourceFile(path)
}
}

@ -0,0 +1,172 @@
/*
* 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.platform.time
import kotlin.math.abs
import kotlin.math.ceil
enum class DayOfWeek(val index: Int) {
SUNDAY(0),
MONDAY(1),
TUESDAY(2),
WEDNESDAY(3),
THURSDAY(4),
FRIDAY(5),
SATURDAY(6),
}
data class Timestamp(val millisSince1970: Long) {
val localDate: LocalDate
get() {
val millisSince2000 = millisSince1970 - 946684800000
val daysSince2000 = millisSince2000 / 86400000
return LocalDate(daysSince2000.toInt())
}
}
data class LocalDate(val daysSince2000: Int) {
var yearCache = -1
var monthCache = -1
var dayCache = -1
constructor(year: Int, month: Int, day: Int) :
this(daysSince2000(year, month, day))
val dayOfWeek: DayOfWeek
get() {
return when (daysSince2000 % 7) {
0 -> DayOfWeek.SATURDAY
1 -> DayOfWeek.SUNDAY
2 -> DayOfWeek.MONDAY
3 -> DayOfWeek.TUESDAY
4 -> DayOfWeek.WEDNESDAY
5 -> DayOfWeek.THURSDAY
else -> DayOfWeek.FRIDAY
}
}
val timestamp: Timestamp
get() {
return Timestamp(946684800000 + daysSince2000.toLong() * 86400000)
}
val year: Int
get() {
if (yearCache < 0) updateYearMonthDayCache()
return yearCache
}
val month: Int
get() {
if (monthCache < 0) updateYearMonthDayCache()
return monthCache
}
val day: Int
get() {
if (dayCache < 0) updateYearMonthDayCache()
return dayCache
}
private fun updateYearMonthDayCache() {
var currYear = 2000
var currDay = 0
while (true) {
val currYearLength = if (isLeapYear(currYear)) 366 else 365
if (daysSince2000 < currDay + currYearLength) {
yearCache = currYear
break
} else {
currYear++
currDay += currYearLength
}
}
var currMonth = 1
val monthOffset = if (isLeapYear(currYear)) leapOffset else nonLeapOffset
while (true) {
if (daysSince2000 < currDay + monthOffset[currMonth]) {
monthCache = currMonth
break
} else {
currMonth++
}
}
currDay += monthOffset[currMonth - 1]
dayCache = daysSince2000 - currDay + 1
}
fun isOlderThan(other: LocalDate): Boolean {
return daysSince2000 < other.daysSince2000
}
fun isNewerThan(other: LocalDate): Boolean {
return daysSince2000 > other.daysSince2000
}
fun plus(days: Int): LocalDate {
return LocalDate(daysSince2000 + days)
}
fun minus(days: Int): LocalDate {
return LocalDate(daysSince2000 - days)
}
fun distanceTo(other: LocalDate): Int {
return abs(daysSince2000 - other.daysSince2000)
}
}
interface LocalDateFormatter {
fun shortWeekdayName(date: LocalDate): String
fun shortMonthName(date: LocalDate): String
}
private fun isLeapYear(year: Int): Boolean {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
val leapOffset = arrayOf(
0, 31, 60, 91, 121, 152, 182,
213, 244, 274, 305, 335, 366
)
val nonLeapOffset = arrayOf(
0, 31, 59, 90, 120, 151, 181,
212, 243, 273, 304, 334, 365
)
private fun daysSince2000(year: Int, month: Int, day: Int): Int {
var result = 365 * (year - 2000)
result += ceil((year - 2000) / 4.0).toInt()
result -= ceil((year - 2000) / 100.0).toInt()
result += ceil((year - 2000) / 400.0).toInt()
if (isLeapYear(year)) {
result += leapOffset[month - 1]
} else {
result += nonLeapOffset[month - 1]
}
result += (day - 1)
return result
}

@ -0,0 +1,63 @@
/*
* 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.platform.time
import java.util.Calendar.DAY_OF_MONTH
import java.util.Calendar.DAY_OF_WEEK
import java.util.Calendar.HOUR_OF_DAY
import java.util.Calendar.LONG
import java.util.Calendar.MILLISECOND
import java.util.Calendar.MINUTE
import java.util.Calendar.MONTH
import java.util.Calendar.SECOND
import java.util.Calendar.SHORT
import java.util.Calendar.YEAR
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
fun LocalDate.toGregorianCalendar(): GregorianCalendar {
val cal = GregorianCalendar()
cal.timeZone = TimeZone.getTimeZone("GMT")
cal.set(MILLISECOND, 0)
cal.set(SECOND, 0)
cal.set(MINUTE, 0)
cal.set(HOUR_OF_DAY, 0)
cal.set(YEAR, this.year)
cal.set(MONTH, this.month - 1)
cal.set(DAY_OF_MONTH, this.day)
return cal
}
class JavaLocalDateFormatter(private val locale: Locale) : LocalDateFormatter {
override fun shortMonthName(date: LocalDate): String {
val cal = date.toGregorianCalendar()
val longName = cal.getDisplayName(MONTH, LONG, locale)
val shortName = cal.getDisplayName(MONTH, SHORT, locale)
// For some locales, such as Japan, SHORT name is exceedingly short
return if (longName.length <= 3) longName else shortName
}
override fun shortWeekdayName(date: LocalDate): String {
val cal = date.toGregorianCalendar()
return cal.getDisplayName(DAY_OF_WEEK, SHORT, locale)
}
}

@ -0,0 +1,189 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.TextAlign
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.round
class BarChart(
var theme: Theme,
var dateFormatter: LocalDateFormatter,
) : Component {
// Data
var series = mutableListOf<List<Double>>()
var colors = mutableListOf<Color>()
var axis = listOf<LocalDate>()
// Style
var paddingTop = 20.0
var paddingLeft = 0.0
var paddingRight = 0.0
var footerHeight = 40.0
var barGroupMargin = 4.0
var barMargin = 3.0
var barWidth = 12.0
var nGridlines = 6
var backgroundColor = theme.cardBackgroundColor
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
val nSeries = series.size
val barGroupWidth = 2 * barGroupMargin + nSeries * (barWidth + 2 * barMargin)
val safeWidth = width - paddingLeft - paddingRight
val nColumns = floor((safeWidth) / barGroupWidth).toInt()
val marginLeft = (safeWidth - nColumns * barGroupWidth) / 2
val maxBarHeight = height - footerHeight - paddingTop
var maxValue = series.map { it.max()!! }.max()!!
maxValue = max(maxValue, 1.0)
canvas.setColor(backgroundColor)
canvas.fillRect(0.0, 0.0, width, height)
fun barGroupOffset(c: Int) = marginLeft + paddingLeft +
(c) * barGroupWidth
fun barOffset(c: Int, s: Int) = barGroupOffset(c) +
barGroupMargin +
s * (barWidth + 2 * barMargin) +
barMargin
fun drawColumn(s: Int, c: Int) {
val dataColumn = nColumns - c - 1
val value = if (dataColumn < series[s].size) series[s][dataColumn] else 0.0
if (value <= 0) return
val perc = value / maxValue
val barHeight = round(maxBarHeight * perc)
val x = barOffset(c, s)
val y = height - footerHeight - barHeight
canvas.setColor(colors[s])
val r = round(barWidth * 0.15)
canvas.fillRect(x, y + r, barWidth, barHeight - r)
canvas.fillRect(x + r, y, barWidth - 2 * r, r)
canvas.fillCircle(x + r, y + r, r)
canvas.fillCircle(x + barWidth - r, y + r, r)
canvas.setFontSize(theme.smallTextSize)
canvas.setTextAlign(TextAlign.CENTER)
canvas.setColor(backgroundColor)
canvas.fillRect(
x - barMargin,
y - theme.smallTextSize * 1.25,
barWidth + 2 * barMargin,
theme.smallTextSize * 1.0
)
canvas.setColor(colors[s])
canvas.drawText(
value.toShortString(),
x + barWidth / 2,
y - theme.smallTextSize * 0.80
)
}
fun drawSeries(s: Int) {
for (c in 0 until nColumns) drawColumn(s, c)
}
fun drawMajorGrid() {
canvas.setStrokeWidth(1.0)
if (nSeries > 1) {
canvas.setColor(
backgroundColor.blendWith(
theme.lowContrastTextColor,
0.5
)
)
for (c in 0 until nColumns - 1) {
val x = barGroupOffset(c)
canvas.drawLine(x, paddingTop, x, paddingTop + maxBarHeight)
}
}
for (k in 1 until nGridlines) {
val pct = 1.0 - (k.toDouble() / (nGridlines - 1))
val y = paddingTop + maxBarHeight * pct
canvas.setColor(theme.lowContrastTextColor)
canvas.setStrokeWidth(0.5)
canvas.drawLine(0.0, y, width, y)
}
}
fun drawAxis() {
val y = paddingTop + maxBarHeight
canvas.setColor(backgroundColor)
canvas.fillRect(0.0, y, width, height - y)
canvas.setColor(theme.lowContrastTextColor)
canvas.drawLine(0.0, y, width, y)
canvas.setColor(theme.mediumContrastTextColor)
canvas.setTextAlign(TextAlign.CENTER)
var prevMonth = -1
var prevYear = -1
val isLargeInterval = (axis[0].distanceTo(axis[1]) > 300)
for (c in 0 until nColumns) {
val x = barGroupOffset(c)
val dataColumn = nColumns - c - 1
if (dataColumn >= axis.size) continue
val date = axis[dataColumn]
if (isLargeInterval) {
canvas.drawText(
date.year.toString(),
x + barGroupWidth / 2,
y + theme.smallTextSize * 1.0
)
} else {
if (date.month != prevMonth) {
canvas.drawText(
dateFormatter.shortMonthName(date),
x + barGroupWidth / 2,
y + theme.smallTextSize * 1.0
)
} else {
canvas.drawText(
date.day.toString(),
x + barGroupWidth / 2,
y + theme.smallTextSize * 1.0
)
}
if (date.year != prevYear) {
canvas.drawText(
date.year.toString(),
x + barGroupWidth / 2,
y + theme.smallTextSize * 2.3
)
}
}
prevMonth = date.month
prevYear = date.year
}
}
drawMajorGrid()
for (k in 0 until nSeries) drawSeries(k)
drawAxis()
}
}

@ -0,0 +1,143 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.TextAlign
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
import kotlin.math.floor
import kotlin.math.round
class CalendarChart(
var today: LocalDate,
var color: Color,
var theme: Theme,
var dateFormatter: LocalDateFormatter
) : Component {
var padding = 5.0
var backgroundColor = Color(0xFFFFFF)
var squareSpacing = 1.0
var series = listOf<Double>()
var scrollPosition = 0
private var squareSize = 0.0
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
canvas.setColor(backgroundColor)
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 topLeftDate = today.minus(topLeftOffset)
repeat(nColumns) { column ->
val topOffset = topLeftOffset - 7 * column
val topDate = topLeftDate.plus(7 * column)
drawColumn(canvas, column, topDate, topOffset)
}
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 + squareSize * (row + 1) + squareSize / 2
)
}
}
private fun drawColumn(
canvas: Canvas,
column: Int,
topDate: LocalDate,
topOffset: Int
) {
drawHeader(canvas, column, topDate)
repeat(7) { row ->
val offset = topOffset - row
val date = topDate.plus(row)
if (offset < 0) return
drawSquare(
canvas,
padding + column * squareSize,
padding + (row + 1) * squareSize,
squareSize - squareSpacing,
squareSize - squareSpacing,
date,
offset
)
}
}
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
)
}
}
private fun drawSquare(
canvas: Canvas,
x: Double,
y: Double,
width: Double,
height: Double,
date: LocalDate,
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)
canvas.setColor(squareColor)
canvas.fillRect(x, y, width, height)
canvas.setColor(textColor)
canvas.drawText(date.day.toString(), x + width / 2, y + width / 2)
}
}

@ -0,0 +1,48 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import org.isoron.platform.gui.FontAwesome
class CheckmarkButton(
private val value: Int,
private val color: Color,
private val theme: Theme
) : Component {
override fun draw(canvas: Canvas) {
canvas.setFont(Font.FONT_AWESOME)
canvas.setFontSize(theme.smallTextSize * 1.5)
canvas.setColor(
when (value) {
2 -> color
else -> theme.lowContrastTextColor
}
)
val text = when (value) {
0 -> FontAwesome.TIMES
else -> FontAwesome.CHECK
}
canvas.drawText(text, canvas.getWidth() / 2.0, canvas.getHeight() / 2.0)
}
}

@ -0,0 +1,61 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
class HabitListHeader(
private val today: LocalDate,
private val nButtons: Int,
private val theme: Theme,
private val fmt: LocalDateFormatter
) : Component {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
val buttonSize = theme.checkmarkButtonSize
canvas.setColor(theme.headerBackgroundColor)
canvas.fillRect(0.0, 0.0, width, height)
canvas.setColor(theme.headerBorderColor)
canvas.setStrokeWidth(0.5)
canvas.drawLine(0.0, height - 0.5, width, height - 0.5)
canvas.setColor(theme.headerTextColor)
canvas.setFont(Font.BOLD)
canvas.setFontSize(theme.smallTextSize)
repeat(nButtons) { index ->
val date = today.minus(nButtons - index - 1)
val name = fmt.shortWeekdayName(date).toUpperCase()
val number = date.day.toString()
val x = width - (index + 1) * buttonSize + buttonSize / 2
val y = height / 2
canvas.drawText(name, x, y - theme.smallTextSize * 0.6)
canvas.drawText(number, x, y + theme.smallTextSize * 0.6)
}
}
}

@ -0,0 +1,78 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import java.lang.String.format
import kotlin.math.round
fun Double.toShortString(): String = when {
this >= 1e9 -> format("%.1fG", this / 1e9)
this >= 1e8 -> format("%.0fM", this / 1e6)
this >= 1e7 -> format("%.1fM", this / 1e6)
this >= 1e6 -> format("%.1fM", this / 1e6)
this >= 1e5 -> format("%.0fk", this / 1e3)
this >= 1e4 -> format("%.1fk", this / 1e3)
this >= 1e3 -> format("%.1fk", this / 1e3)
this >= 1e2 -> format("%.0f", this)
this >= 1e1 -> when {
round(this) == this -> format("%.0f", this)
else -> format("%.1f", this)
}
else -> when {
round(this) == this -> format("%.0f", this)
round(this * 10) == this * 10 -> format("%.1f", this)
else -> format("%.2f", this)
}
}
class NumberButton(
val color: Color,
val value: Double,
val threshold: Double,
val units: String,
val theme: Theme
) : Component {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
val em = theme.smallTextSize
canvas.setColor(
when {
value >= threshold -> color
value >= 0.01 -> theme.mediumContrastTextColor
else -> theme.lowContrastTextColor
}
)
canvas.setFontSize(theme.regularTextSize)
canvas.setFont(Font.BOLD)
canvas.drawText(value.toShortString(), width / 2, height / 2 - 0.6 * em)
canvas.setFontSize(theme.smallTextSize)
canvas.setFont(Font.REGULAR)
canvas.drawText(units, width / 2, height / 2 + 0.6 * em)
}
}

@ -0,0 +1,58 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import java.lang.String.format
import kotlin.math.max
import kotlin.math.min
class Ring(
val color: Color,
val percentage: Double,
val thickness: Double,
val radius: Double,
val theme: Theme,
val label: Boolean = false
) : Component {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
val angle = 360.0 * max(0.0, min(360.0, percentage))
canvas.setColor(theme.lowContrastTextColor)
canvas.fillCircle(width / 2, height / 2, radius)
canvas.setColor(color)
canvas.fillArc(width / 2, height / 2, radius, 90.0, -angle)
canvas.setColor(theme.cardBackgroundColor)
canvas.fillCircle(width / 2, height / 2, radius - thickness)
if (label) {
canvas.setColor(color)
canvas.setFontSize(radius * 0.4)
canvas.drawText(format("%.0f%%", percentage * 100), width / 2, height / 2)
}
}
}

@ -0,0 +1,70 @@
/*
* 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.core.ui.views
import org.isoron.platform.gui.Color
abstract class Theme {
val toolbarColor = Color(0xffffff)
val lowContrastTextColor = Color(0xe0e0e0)
val mediumContrastTextColor = Color(0x9E9E9E)
val highContrastTextColor = Color(0x202020)
val cardBackgroundColor = Color(0xFFFFFF)
val appBackgroundColor = Color(0xf4f4f4)
val toolbarBackgroundColor = Color(0xf4f4f4)
val statusBarBackgroundColor = Color(0x333333)
val headerBackgroundColor = Color(0xeeeeee)
val headerBorderColor = Color(0xcccccc)
val headerTextColor = mediumContrastTextColor
val itemBackgroundColor = Color(0xffffff)
fun color(paletteIndex: Int): Color {
return when (paletteIndex) {
0 -> Color(0xD32F2F)
1 -> Color(0x512DA8)
2 -> Color(0xF57C00)
3 -> Color(0xFF8F00)
4 -> Color(0xF9A825)
5 -> Color(0xAFB42B)
6 -> Color(0x7CB342)
7 -> Color(0x388E3C)
8 -> Color(0x00897B)
9 -> Color(0x00ACC1)
10 -> Color(0x039BE5)
11 -> Color(0x1976D2)
12 -> Color(0x303F9F)
13 -> Color(0x5E35B1)
14 -> Color(0x8E24AA)
15 -> Color(0xD81B60)
16 -> Color(0x5D4037)
else -> Color(0x000000)
}
}
val checkmarkButtonSize = 48.0
val smallTextSize = 10.0
val regularTextSize = 17.0
}
class LightTheme : Theme()

@ -0,0 +1,77 @@
/*
* Copyright (C) 2016-2020 Á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.platform.gui
import junit.framework.Assert.fail
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.junit.Test
import java.awt.image.BufferedImage
import java.awt.image.BufferedImage.TYPE_INT_ARGB
class JavaCanvasTest {
@Test
fun run() = runBlocking {
assertRenders("components/CanvasTest.png", createCanvas(500, 400).apply { drawTestImage() })
}
}
fun createCanvas(w: Int, h: Int) = JavaCanvas(BufferedImage(2 * w, 2 * h, TYPE_INT_ARGB), 2.0)
suspend fun assertRenders(
path: String,
canvas: Canvas
) {
val actualImage = canvas.toImage()
val failedActualPath = "/tmp/failed/$path"
val failedExpectedPath = failedActualPath.replace(
".png",
".expected.png"
)
val failedDiffPath = failedActualPath.replace(".png", ".diff.png")
val fileOpener = JavaFileOpener()
val expectedFile = fileOpener.openResourceFile(path)
if (expectedFile.exists()) {
val expectedImage = expectedFile.toImage()
val diffImage = expectedFile.toImage()
diffImage.diff(actualImage)
val distance = diffImage.averageLuminosity * 100
if (distance >= 1.0) {
expectedImage.export(failedExpectedPath)
actualImage.export(failedActualPath)
diffImage.export(failedDiffPath)
fail("Images differ (distance=$distance)")
}
} else {
actualImage.export(failedActualPath)
fail("Expected image file is missing. Actual image: $failedActualPath")
}
}
suspend fun assertRenders(
width: Int,
height: Int,
expectedPath: String,
component: Component,
) {
val canvas = createCanvas(width, height)
component.draw(canvas)
assertRenders(expectedPath, canvas)
}

@ -0,0 +1,47 @@
/*
* 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.ui.views.BarChart
import org.isoron.uhabits.core.ui.views.LightTheme
import org.junit.Test
import java.util.Locale
class BarChartTest {
val base = "components/BarChart"
val today = LocalDate(2015, 1, 25)
val fmt = JavaLocalDateFormatter(Locale.US)
val theme = LightTheme()
val component = BarChart(theme, fmt)
val axis = (0..100).map { today.minus(it) }
val series1 = listOf(200.0, 0.0, 150.0, 137.0, 0.0, 0.0, 500.0, 30.0, 100.0, 0.0, 300.0)
@Test
fun testDraw() = runBlocking {
component.axis = axis
component.series.add(series1)
component.colors.add(theme.color(8))
assertRenders(300, 200, "$base/base.png", component)
}
}
Loading…
Cancel
Save