mirror of https://github.com/iSoron/uhabits.git
parent
a9acbd6cab
commit
98ec166666
@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.widgets
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import org.isoron.uhabits.BaseViewTest
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.models.Frequency
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class HabitListWidgetTest : BaseViewTest() {
|
||||
private lateinit var listOfHabits: ArrayList<Habit>
|
||||
private lateinit var view: FrameLayout
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
setTheme(R.style.WidgetTheme)
|
||||
prefs.widgetOpacity = 255
|
||||
|
||||
listOfHabits.add(fixtures.createLongNumericalHabit().apply {
|
||||
color = PaletteColor(11)
|
||||
frequency = Frequency.WEEKLY
|
||||
recompute()
|
||||
})
|
||||
listOfHabits.add(fixtures.createLongHabit().apply {
|
||||
color = PaletteColor(1)
|
||||
frequency = Frequency.DAILY
|
||||
recompute()
|
||||
})
|
||||
|
||||
val widget = HabitListWidget(targetContext, 0, listOfHabits)
|
||||
view = convertToView(widget, 400, 400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsInstalled() {
|
||||
assertWidgetProviderIsInstalled(HabitListWidgetProvider::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun testRender() {
|
||||
assertRenders(view, PATH + "render.png")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PATH = "widgets/HabitListWidget/"
|
||||
}
|
||||
}
|
@ -0,0 +1,620 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.activities.common.views
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
import kotlin.math.max
|
||||
import androidx.core.graphics.withTranslation
|
||||
import org.isoron.uhabits.HabitsApplication
|
||||
import org.isoron.uhabits.activities.habits.list.views.toShortString
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.NO
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType
|
||||
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.NumericalHabitType.AT_LEAST
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType.AT_MOST
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.IndividualHabitListState
|
||||
import org.isoron.uhabits.utils.dim
|
||||
import kotlin.math.min
|
||||
import org.isoron.platform.gui.toInt
|
||||
|
||||
private val BOLD_TYPEFACE = Typeface.create("sans-serif-condensed", Typeface.BOLD)
|
||||
private val NORMAL_TYPEFACE = Typeface.create("sans-serif-condensed", Typeface.NORMAL)
|
||||
|
||||
|
||||
class HabitListChart : View {
|
||||
private var habits = emptyList<IndividualHabitListState>()
|
||||
private var weekDayStrings = emptyList<String>()
|
||||
private var dateStrings = emptyList<String>()
|
||||
private var amtHabits = 0
|
||||
private var maxCheckMarks = 0
|
||||
private var numCheckMarks = 0
|
||||
|
||||
private var habitRowSize = 0
|
||||
private var checkMarkSize = 18.dpToPx()
|
||||
private var textBoxSize = checkMarkSize * 2
|
||||
private var padding = dpToPixels(context, 4f)
|
||||
private var scaleFactor = 25f
|
||||
|
||||
private val rect = RectF()
|
||||
private val barRect = RectF()
|
||||
|
||||
private var backGroundPaint: Paint? = null
|
||||
private var lowContrastTextColor = 0 // contrast20
|
||||
private var mediumContrastTextColor = 0 // contrast40
|
||||
private var highContrastTextColor = 0 // contrast60
|
||||
|
||||
private val app = context.applicationContext as HabitsApplication
|
||||
private val preferences = app.component.preferences
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 12.spToPx()
|
||||
color = Color.WHITE
|
||||
}
|
||||
|
||||
private val pText: TextPaint = TextPaint().apply {
|
||||
textSize = (dim(R.dimen.regularTextSize) * 1.5).toFloat()
|
||||
typeface = BOLD_TYPEFACE
|
||||
textAlign = Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val pNumber: TextPaint = TextPaint().apply {
|
||||
textSize = dim(R.dimen.smallTextSize)
|
||||
typeface = BOLD_TYPEFACE
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val pUnit: TextPaint = TextPaint().apply {
|
||||
textSize = getDimension(context, R.dimen.smallerTextSize)
|
||||
typeface = NORMAL_TYPEFACE
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
// width
|
||||
var widthSpec = widthSpec
|
||||
val width = MeasureSpec.getSize(widthSpec)
|
||||
|
||||
// responsive height
|
||||
var heightSpec = heightSpec
|
||||
habitRowSize = resources.getDimensionPixelSize(R.dimen.baseSize ) + 60
|
||||
val epsilonOfRowSize = 10
|
||||
val height = MeasureSpec.getSize(heightSpec)
|
||||
|
||||
amtHabits = min(habits.size, (height / habitRowSize) - 1)
|
||||
var habitHeight = habitRowSize * (amtHabits + 1)
|
||||
|
||||
if (amtHabits < 1){ // Always have at least one habit. Edge Case
|
||||
amtHabits++
|
||||
habitRowSize = height / (amtHabits + 1)
|
||||
habitHeight = height
|
||||
}
|
||||
else if (habits.size != amtHabits && height - habitHeight >= habitRowSize * 0.50 && (height / (amtHabits + 1) >= habitRowSize - epsilonOfRowSize) ){ // If enough room at the bottom to fit another one, fit another one.
|
||||
amtHabits++
|
||||
habitRowSize = height / (amtHabits + 1)
|
||||
habitHeight = height
|
||||
}
|
||||
else if (height - habitHeight < habitRowSize * 0.50 && (height / (amtHabits + 1) <= habitRowSize + epsilonOfRowSize) ){ // If not enough room to fit another one, make them all slightly bigger
|
||||
habitRowSize = height / (amtHabits + 1)
|
||||
habitHeight = height
|
||||
}
|
||||
|
||||
heightSpec = MeasureSpec.makeMeasureSpec(habitHeight, MeasureSpec.EXACTLY)
|
||||
widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
|
||||
setMeasuredDimension(widthSpec, heightSpec)
|
||||
|
||||
|
||||
// Amount of Checkmarks and the size of the habit text
|
||||
val firstCheckMarkWithPadding = checkMarkSize + (padding * 4)
|
||||
val otherCheckMarkWithPadding = checkMarkSize + (padding * 8)
|
||||
fun getCheckMarkWithPadding(i: Int) = firstCheckMarkWithPadding + i * otherCheckMarkWithPadding
|
||||
val ringSizeWithPadding = checkMarkSize + (padding * 1.5)
|
||||
val rowPadding = padding * 2
|
||||
val minTextBoxSize = checkMarkSize * 2 + padding
|
||||
|
||||
if (width < 150.dpToPx()) {
|
||||
Log.e("JMO", "1")
|
||||
|
||||
numCheckMarks = 1
|
||||
textBoxSize = (width - (rowPadding) - ( firstCheckMarkWithPadding ) - padding - (ringSizeWithPadding)).toFloat()
|
||||
}
|
||||
else {
|
||||
Log.e("JMO", "2")
|
||||
numCheckMarks = ((((width + (padding * 4) - (rowPadding)) * 0.62) / otherCheckMarkWithPadding)).toInt()
|
||||
textBoxSize = (width - (rowPadding) - ( getCheckMarkWithPadding(numCheckMarks - 1) )- padding - (ringSizeWithPadding)).toFloat()
|
||||
if (textBoxSize - otherCheckMarkWithPadding >= minTextBoxSize){
|
||||
numCheckMarks++
|
||||
textBoxSize -= otherCheckMarkWithPadding
|
||||
}
|
||||
}
|
||||
if (numCheckMarks > maxCheckMarks)
|
||||
numCheckMarks = maxCheckMarks
|
||||
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
backGroundPaint = Paint()
|
||||
backGroundPaint!!.textAlign = Paint.Align.CENTER
|
||||
backGroundPaint!!.isAntiAlias = true
|
||||
val res = StyledResources(context)
|
||||
lowContrastTextColor = res.getColor(R.attr.contrast20)
|
||||
mediumContrastTextColor = res.getColor(R.attr.contrast40)
|
||||
highContrastTextColor = res.getColor(R.attr.contrast60)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (habits.isEmpty()) return
|
||||
|
||||
val marginTop = (height - (habitRowSize * (amtHabits + 1))) / 2.0f
|
||||
rect[0f, marginTop, width.toFloat()] = marginTop + habitRowSize
|
||||
|
||||
// Draw Dates Header
|
||||
drawHeaderRow(canvas, rect)
|
||||
rect.offset(0f, habitRowSize.toFloat())
|
||||
|
||||
// Draw the habit rows
|
||||
for (i in 0 until minOf(habits.size, amtHabits)) {
|
||||
drawRow(canvas, habits[i], rect)
|
||||
rect.offset(0f, habitRowSize.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawHeaderRow(canvas: Canvas, rect: RectF) {
|
||||
val round = dpToPixels(context, 2f)
|
||||
|
||||
// Draw background box
|
||||
backGroundPaint!!.color = Color.TRANSPARENT
|
||||
barRect[rect.left + padding, rect.top + habitRowSize * 0.05f, rect.right - padding] =
|
||||
rect.bottom - habitRowSize * 0.05f
|
||||
canvas.drawRoundRect(barRect, round, round, backGroundPaint!!)
|
||||
|
||||
// paint
|
||||
val paint = TextPaint().apply {
|
||||
color = Color.WHITE
|
||||
isAntiAlias = true
|
||||
textSize = dim(R.dimen.tinyTextSize)
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
val em = paint.measureText("m")
|
||||
val checkMarkCenterX = ((padding * 4) + checkMarkSize/2)
|
||||
val checkMarkCenterY = rect.centerY()
|
||||
|
||||
// Draw dates
|
||||
repeat(numCheckMarks) { index ->
|
||||
|
||||
val centerX = rect.right - (checkMarkCenterX * (index + 1) + (padding * 4) * (index))
|
||||
|
||||
val y1 = checkMarkCenterY - 0.25 * em
|
||||
val y2 = checkMarkCenterY + 1.25 * em
|
||||
var dateIndex = numCheckMarks - index - 1
|
||||
if (preferences.isCheckmarkSequenceReversed){
|
||||
dateIndex = index
|
||||
}
|
||||
|
||||
canvas.drawText(weekDayStrings[dateIndex], centerX, y1.toFloat(), paint)
|
||||
canvas.drawText(dateStrings[dateIndex], centerX, y2.toFloat(), paint)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun drawRow(canvas: Canvas, habit: IndividualHabitListState, rect: RectF) {
|
||||
val round = dpToPixels(context, 2f)
|
||||
|
||||
// Draw background box
|
||||
backGroundPaint!!.color = lowContrastTextColor
|
||||
barRect[rect.left + padding, rect.top + habitRowSize * 0.05f, rect.right - padding] =
|
||||
rect.bottom - habitRowSize * 0.05f
|
||||
canvas.drawRoundRect(barRect, round, round, backGroundPaint!!)
|
||||
|
||||
// ScoreRing
|
||||
val ringSize = checkMarkSize.toInt()
|
||||
val ringCenterX = (rect.left + (padding * 1.5) + ringSize/2).toFloat()
|
||||
val ringCenterY = rect.centerY()
|
||||
drawRingView(
|
||||
canvas = canvas,
|
||||
centerX = ringCenterX,
|
||||
centerY = ringCenterY,
|
||||
percentage = habit.score,
|
||||
color = habit.color.toInt()
|
||||
)
|
||||
|
||||
// CheckMarks
|
||||
val checkMarkCenterX = ((padding * 4) + checkMarkSize/2)
|
||||
val checkMarkCenterY = rect.centerY()
|
||||
|
||||
for (index in 1..numCheckMarks) { // 1 , 2, 3 if numCheckMarks == 3
|
||||
|
||||
var habitCheckIndex = numCheckMarks - index
|
||||
if (preferences.isCheckmarkSequenceReversed){
|
||||
habitCheckIndex = index - 1
|
||||
}
|
||||
|
||||
// Checkbox Rectangle
|
||||
val centerX = rect.right - ((checkMarkCenterX * index) + (padding * 4) * (index - 1))
|
||||
val centerY = checkMarkCenterY
|
||||
val checkRect = RectF()
|
||||
checkRect.set(
|
||||
centerX - checkMarkSize / 2f,
|
||||
centerY - checkMarkSize / 2f,
|
||||
centerX + checkMarkSize / 2f,
|
||||
centerY + checkMarkSize / 2f
|
||||
)
|
||||
|
||||
val paint = Paint().apply {
|
||||
this.color = habit.color.toInt()
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 1.dpToPx()
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
if (habit.isNumerical) { // If Numerical Habit
|
||||
drawNumberCheck(
|
||||
canvas = canvas,
|
||||
checkRect = checkRect,
|
||||
value = (max(-1, (habit.values[habitCheckIndex])) / 1000.0),
|
||||
threshold = habit.targetValue,
|
||||
units = habit.unit,
|
||||
targetType = habit.targetType,
|
||||
paint = paint
|
||||
)
|
||||
}
|
||||
else { // If Non Numerical Habit
|
||||
drawNonNumeric(
|
||||
canvas = canvas,
|
||||
checkRect = checkRect,
|
||||
value = (max(-1, habit.values[habitCheckIndex])),
|
||||
paint = paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw habit name
|
||||
drawAdaptiveText(
|
||||
canvas = canvas,
|
||||
text = habit.name,
|
||||
x = ringCenterX + ringSize/2 + padding,
|
||||
y = rect.top,
|
||||
width= textBoxSize.toInt(), // textAreaWidth,
|
||||
height = habitRowSize,
|
||||
paint = textPaint,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private fun drawRingView(
|
||||
canvas: Canvas,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
percentage: Float,
|
||||
color: Int
|
||||
) {
|
||||
val thickness = checkMarkSize * 0.22f
|
||||
|
||||
val bgPaint = Paint().apply {
|
||||
this.color = Color.argb(25, Color.red(color), Color.green(color), Color.blue(color))
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = thickness
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
val fgPaint = Paint().apply {
|
||||
this.color = color
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = thickness
|
||||
strokeCap = Paint.Cap.BUTT
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
val rect = RectF(
|
||||
centerX - checkMarkSize / 2 + thickness / 2,
|
||||
centerY - checkMarkSize / 2 + thickness / 2,
|
||||
centerX - checkMarkSize / 2 + checkMarkSize - thickness / 2,
|
||||
centerY - checkMarkSize / 2 + checkMarkSize - thickness / 2
|
||||
)
|
||||
|
||||
// Draw background
|
||||
canvas.drawArc(rect, 0f, 360f, false, bgPaint)
|
||||
|
||||
// Draw progress
|
||||
if (percentage > 0) {
|
||||
canvas.drawArc(rect, -90f, 360f * percentage, false, fgPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawAdaptiveText(
|
||||
canvas: Canvas,
|
||||
text: String,
|
||||
x: Float,
|
||||
y: Float,
|
||||
width: Int,
|
||||
height: Int,
|
||||
paint: TextPaint,
|
||||
maxLines: Int = 2
|
||||
) {
|
||||
// Create text layout
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setLineSpacing(0f, 1f)
|
||||
.setEllipsize(TextUtils.TruncateAt.END)
|
||||
.setMaxLines(maxLines)
|
||||
.build()
|
||||
|
||||
// if single line, top-aligned if multi-line
|
||||
val verticalPos = if (layout.lineCount == 1) {
|
||||
y + (height - layout.height) / 2
|
||||
} else {
|
||||
y + (height - layout.height) / 2
|
||||
}
|
||||
|
||||
canvas.withTranslation(x, verticalPos) {
|
||||
layout.draw(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun drawNumberCheck(
|
||||
canvas: Canvas,
|
||||
checkRect: RectF,
|
||||
value : Double,
|
||||
threshold: Double,
|
||||
units: String,
|
||||
targetType : NumericalHabitType,
|
||||
paint : Paint
|
||||
) {
|
||||
// Color
|
||||
val activeColor = when {
|
||||
value == SKIP.toDouble() / 1000 -> paint.color
|
||||
value < 0.0 -> mediumContrastTextColor
|
||||
(targetType == AT_LEAST) && (value >= threshold) -> paint.color
|
||||
(targetType == AT_MOST) && (value <= threshold) -> paint.color
|
||||
else -> highContrastTextColor
|
||||
}
|
||||
paint.color = activeColor
|
||||
|
||||
// Prepare text
|
||||
val numberText = if (value >= 0) value.toShortString() else "0"
|
||||
|
||||
pNumber.color = activeColor
|
||||
pUnit.color = activeColor
|
||||
|
||||
// Draw
|
||||
val em = pNumber.measureText("m")
|
||||
var questionMarkScale = 1.0
|
||||
val verticalSpacing = em * 0.3f
|
||||
|
||||
if (units.isNotBlank()){ // if have units
|
||||
questionMarkScale = 0.8
|
||||
checkRect.offset(0f, - verticalSpacing)
|
||||
}
|
||||
|
||||
// Draw Number
|
||||
when {
|
||||
value == SKIP.toDouble() / 1000 -> {
|
||||
drawSkipLine(canvas, checkRect, paint)
|
||||
}
|
||||
value >= 0 -> {
|
||||
canvas.drawText(numberText,checkRect.centerX(),checkRect.centerY() + em / 3, pNumber)
|
||||
}
|
||||
preferences.areQuestionMarksEnabled -> {
|
||||
drawSimpleText(canvas, checkRect, pNumber, "?", questionMarkScale)
|
||||
}
|
||||
else -> {
|
||||
canvas.drawText(numberText,checkRect.centerX(),checkRect.centerY() + em / 3, pNumber)
|
||||
}
|
||||
}
|
||||
// Draw Units
|
||||
if (units.isNotBlank()) { // if have units
|
||||
val unitsSub = units.substring(0, min(units.length, 7))
|
||||
checkRect.offset(0f, +verticalSpacing + em)
|
||||
canvas.drawText(unitsSub, checkRect.centerX(), checkRect.centerY() + em / 3, pUnit)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun drawNonNumeric(
|
||||
canvas: Canvas,
|
||||
checkRect : RectF,
|
||||
value: Int,
|
||||
paint: Paint)
|
||||
{
|
||||
// Color
|
||||
paint.color = when (value) {
|
||||
YES_MANUAL, YES_AUTO, SKIP -> paint.color
|
||||
NO -> {
|
||||
if (preferences.areQuestionMarksEnabled) {
|
||||
highContrastTextColor
|
||||
} else {
|
||||
mediumContrastTextColor
|
||||
}
|
||||
}
|
||||
else -> mediumContrastTextColor
|
||||
}
|
||||
|
||||
// Which CheckMark
|
||||
when (value) {
|
||||
SKIP -> drawSkipLine(canvas, checkRect, paint)
|
||||
NO -> drawXMark(canvas, checkRect, paint)
|
||||
UNKNOWN -> {
|
||||
if (preferences.areQuestionMarksEnabled) {
|
||||
drawSimpleText(canvas, checkRect, paint, "?")
|
||||
} else {
|
||||
drawXMark(canvas, checkRect, paint)
|
||||
}
|
||||
}
|
||||
YES_AUTO -> {
|
||||
drawCheckMark(canvas, checkRect, paint, false)
|
||||
drawCheckMark(canvas, checkRect, paint, true)
|
||||
}
|
||||
else -> drawCheckMark(canvas, checkRect, paint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawCheckMark(
|
||||
canvas: Canvas,
|
||||
checkRect: RectF,
|
||||
paint: Paint,
|
||||
isAuto: Boolean = false
|
||||
) {
|
||||
val scale = checkMarkSize / scaleFactor
|
||||
canvas.withTranslation(checkRect.left , checkRect.top) {
|
||||
scale(scale, scale)
|
||||
|
||||
// Draw Checkmark
|
||||
val path = Path().apply {
|
||||
moveTo(9f, 16.17f)
|
||||
lineTo(4.83f, 12f)
|
||||
lineTo(3.41f, 13.41f)
|
||||
lineTo(9f, 19f)
|
||||
lineTo(21f, 7f)
|
||||
lineTo(19.59f, 5.59f)
|
||||
close()
|
||||
}
|
||||
|
||||
if (isAuto) {
|
||||
// First draw: outline
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = paint.strokeWidth // scale the stroke width
|
||||
drawPath(path, paint)
|
||||
|
||||
// Second draw: inner fill
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = mediumContrastTextColor // your background color
|
||||
drawPath(path, paint)
|
||||
|
||||
} else {
|
||||
// Regular checkmark
|
||||
paint.style = Paint.Style.STROKE
|
||||
drawPath(path, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawSimpleText(
|
||||
canvas: Canvas,
|
||||
checkRect: RectF,
|
||||
paint: Paint,
|
||||
text: String,
|
||||
scale: Double = 1.0
|
||||
) {
|
||||
pText.textSize = (dim(R.dimen.regularTextSize) * 1.5 * scale).toFloat()
|
||||
pText.color = paint.color
|
||||
val em = pText.measureText("m")
|
||||
|
||||
canvas.drawText(
|
||||
text,
|
||||
checkRect.centerX(),
|
||||
checkRect.centerY() + em / 2,
|
||||
pText
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawSkipLine(
|
||||
canvas: Canvas,
|
||||
checkRect: RectF,
|
||||
paint: Paint,
|
||||
) {
|
||||
val scale = checkMarkSize / scaleFactor
|
||||
canvas.withTranslation(checkRect.left, checkRect.top) {
|
||||
canvas.scale(scale, scale)
|
||||
|
||||
val lineWidth = 12f
|
||||
val startX = 12f - lineWidth / 2
|
||||
val endX = 12f + lineWidth / 2
|
||||
val y = 12f
|
||||
|
||||
drawLine(startX, y, endX, y, paint)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun drawXMark(
|
||||
canvas: Canvas,
|
||||
checkRect: RectF,
|
||||
paint: Paint,
|
||||
) {
|
||||
val scale = checkMarkSize / scaleFactor
|
||||
canvas.withTranslation(checkRect.left, checkRect.top) {
|
||||
canvas.scale(scale, scale)
|
||||
|
||||
// Create the X path
|
||||
val path = Path().apply {
|
||||
moveTo(6f, 6f)
|
||||
lineTo(18f, 18f)
|
||||
|
||||
moveTo(6f, 18f)
|
||||
lineTo(18f, 6f)
|
||||
}
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun setHabits(habits: List<IndividualHabitListState>) {
|
||||
this.habits = habits
|
||||
requestLayout()
|
||||
}
|
||||
fun setHeaderDates(weekDayStrings : List<String>, dateStrings : List<String>){
|
||||
this.weekDayStrings = weekDayStrings
|
||||
this.dateStrings = dateStrings
|
||||
requestLayout()
|
||||
}
|
||||
fun setMaxCheckMarks(maxCheckMarks : Int){
|
||||
this.maxCheckMarks = maxCheckMarks
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(): Float = this * resources.displayMetrics.density
|
||||
private fun Int.spToPx(): Float = this * resources.displayMetrics.scaledDensity
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.widgets
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.TextView
|
||||
import org.isoron.uhabits.activities.common.views.HabitListChart
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.HabitListCardPresenter
|
||||
import org.isoron.uhabits.core.ui.views.WidgetTheme
|
||||
import org.isoron.uhabits.widgets.views.GraphWidgetView
|
||||
|
||||
|
||||
class HabitListWidget(
|
||||
context: Context,
|
||||
val widgetId: Int,
|
||||
private val habits: List<Habit>,
|
||||
stacked: Boolean = false
|
||||
): BaseWidget(context, widgetId, stacked) {
|
||||
|
||||
override val defaultHeight: Int = 200
|
||||
override val defaultWidth: Int = 200
|
||||
|
||||
override fun getOnClickPendingIntent(context: Context): PendingIntent =
|
||||
pendingIntentFactory.showListHabitsActivity()
|
||||
|
||||
override fun refreshData(view: View) {
|
||||
val maxDays = 10
|
||||
val data = HabitListCardPresenter.buildState(
|
||||
habits = habits,
|
||||
theme = WidgetTheme(),
|
||||
maxDays = maxDays
|
||||
)
|
||||
val widgetView = view as GraphWidgetView
|
||||
widgetView.setBackgroundAlpha(preferedBackgroundAlpha)
|
||||
if (preferedBackgroundAlpha >= 255) widgetView.setShadowAlpha(0x4f)
|
||||
(widgetView.dataView as HabitListChart).apply{
|
||||
setHabits(data.habits)
|
||||
setHeaderDates(data.weekDayStrings, data.dateStrings)
|
||||
setMaxCheckMarks(maxDays)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun buildView() =
|
||||
GraphWidgetView(context, HabitListChart(context)).apply {
|
||||
setTitle("Jordan Test")
|
||||
val title = findViewById<View>(R.id.title) as TextView
|
||||
title.textSize = 0.toFloat()
|
||||
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.widgets
|
||||
|
||||
import android.content.Context
|
||||
import org.isoron.uhabits.HabitsApplication
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.HabitNotFoundException
|
||||
import java.util.ArrayList
|
||||
|
||||
class HabitListWidgetProvider : BaseWidgetProvider() {
|
||||
|
||||
override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
|
||||
val habits = getNullableHabitsFromWidgetId(context, id)
|
||||
return HabitListWidget(context = context, widgetId = id, habits = habits)
|
||||
}
|
||||
|
||||
private fun getNullableHabitsFromWidgetId(context: Context, widgetId: Int): List<Habit> {
|
||||
val app = context.applicationContext as HabitsApplication
|
||||
val widgetPrefs = app.component.widgetPreferences
|
||||
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
|
||||
val habits = app.component.habitList
|
||||
val selectedHabits = ArrayList<Habit>(selectedIds.size)
|
||||
for (id in selectedIds) {
|
||||
val h = habits.getById(id) ?: continue
|
||||
selectedHabits.add(h)
|
||||
}
|
||||
if (selectedHabits.isEmpty()) {
|
||||
throw HabitNotFoundException()
|
||||
}
|
||||
|
||||
return selectedHabits
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.widgets.activities
|
||||
|
||||
import android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_ID
|
||||
import android.appwidget.AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
import android.os.Bundle
|
||||
import android.widget.AbsListView.CHOICE_MODE_MULTIPLE
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import org.isoron.uhabits.HabitsApplication
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.activities.AndroidThemeSwitcher
|
||||
import org.isoron.uhabits.core.preferences.WidgetPreferences
|
||||
import org.isoron.uhabits.widgets.WidgetUpdater
|
||||
|
||||
class HabitPickerDialogMultiple : HabitPickerDialog() {
|
||||
|
||||
private var widgetId = 0
|
||||
private lateinit var widgetPreferences: WidgetPreferences
|
||||
private lateinit var widgetUpdater: WidgetUpdater
|
||||
|
||||
override fun shouldHideNumerical() = false
|
||||
override fun shouldHideBoolean() = false
|
||||
override fun getEmptyMessage() = R.string.no_habits
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val component = (applicationContext as HabitsApplication).component
|
||||
AndroidThemeSwitcher(this, component.preferences).apply()
|
||||
val habitList = component.habitList
|
||||
widgetPreferences = component.widgetPreferences
|
||||
widgetUpdater = component.widgetUpdater
|
||||
widgetId = intent.extras?.getInt(EXTRA_APPWIDGET_ID, INVALID_APPWIDGET_ID) ?: 0
|
||||
|
||||
val habitIds = ArrayList<Long>()
|
||||
val habitNames = ArrayList<String>()
|
||||
for (h in habitList) {
|
||||
if (h.isArchived) continue
|
||||
if (h.isNumerical and shouldHideNumerical()) continue
|
||||
if (!h.isNumerical and shouldHideBoolean()) continue
|
||||
habitIds.add(h.id!!)
|
||||
habitNames.add(h.name)
|
||||
}
|
||||
|
||||
if (habitNames.isEmpty()) {
|
||||
setContentView(R.layout.widget_empty_activity)
|
||||
findViewById<TextView>(R.id.message).setText(getEmptyMessage())
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.widget_configure_activity_multiple)
|
||||
val listView = findViewById<ListView>(R.id.listView)
|
||||
val saveButton = findViewById<Button>(R.id.buttonSave)
|
||||
|
||||
with(listView) {
|
||||
adapter = ArrayAdapter(
|
||||
context,
|
||||
android.R.layout.simple_list_item_multiple_choice,
|
||||
habitNames
|
||||
)
|
||||
choiceMode = CHOICE_MODE_MULTIPLE
|
||||
itemsCanFocus = false
|
||||
}
|
||||
saveButton.setOnClickListener {
|
||||
val selectedIds = mutableListOf<Long>()
|
||||
for (i in 0..listView.count) {
|
||||
if (listView.isItemChecked(i)) {
|
||||
selectedIds.add(habitIds[i])
|
||||
}
|
||||
}
|
||||
confirm(selectedIds)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 39 KiB |
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
~
|
||||
~ 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:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/listView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="vertical"
|
||||
android:layout_weight="1">
|
||||
</ListView>
|
||||
|
||||
<Button android:id="@+id/buttonSave" android:layout_height="wrap_content" android:layout_width="match_parent" android:text="@string/save"/>
|
||||
|
||||
</LinearLayout>
|
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
~
|
||||
~ 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/>.
|
||||
-->
|
||||
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minHeight="50dp"
|
||||
android:minWidth="100dp"
|
||||
android:initialLayout="@layout/widget_graph"
|
||||
android:previewImage="@drawable/widget_preview_habit_list"
|
||||
android:resizeMode="vertical|horizontal"
|
||||
android:updatePeriodMillis="3600000"
|
||||
android:configure="org.isoron.uhabits.widgets.activities.HabitPickerDialogMultiple"
|
||||
android:widgetCategory="home_screen">
|
||||
|
||||
</appwidget-provider>
|
@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* 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.screens.habits.show.views
|
||||
|
||||
import org.isoron.platform.gui.Color
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType
|
||||
import org.isoron.uhabits.core.ui.views.Theme
|
||||
import org.isoron.uhabits.core.utils.DateUtils
|
||||
import java.util.ArrayList
|
||||
import java.util.GregorianCalendar
|
||||
|
||||
data class HabitListState(
|
||||
val habits: List<IndividualHabitListState> = listOf(),
|
||||
val weekDayStrings: List<String>,
|
||||
val dateStrings: List<String>
|
||||
)
|
||||
|
||||
data class IndividualHabitListState(
|
||||
val color: Color,
|
||||
val score: Float,
|
||||
val isNumerical: Boolean,
|
||||
val values: List<Int> = listOf(),
|
||||
val targetValue: Double,
|
||||
val targetType: NumericalHabitType,
|
||||
val name: String,
|
||||
val unit: String,
|
||||
)
|
||||
|
||||
class HabitListCardPresenter {
|
||||
companion object {
|
||||
fun buildState(
|
||||
habits: List<Habit>,
|
||||
theme: Theme,
|
||||
maxDays: Int = 5
|
||||
): HabitListState {
|
||||
val habitStateLists = ArrayList<IndividualHabitListState>()
|
||||
val weekDayStrings = ArrayList<String>()
|
||||
val dateStrings = ArrayList<String>()
|
||||
|
||||
for (habit in habits){
|
||||
habitStateLists.add(IndividualHabitListCardPresenter.buildState(habit, theme, maxDays))
|
||||
}
|
||||
|
||||
// Get Days
|
||||
val day = DateUtils.getStartOfTodayCalendarWithOffset()
|
||||
day.add(GregorianCalendar.DAY_OF_MONTH, 0)
|
||||
repeat(maxDays) { index ->
|
||||
|
||||
val lines = DateUtils.formatHeaderDate(day).uppercase().split("\n")
|
||||
weekDayStrings.add(lines[0])
|
||||
dateStrings.add(lines[1])
|
||||
day.add(GregorianCalendar.DAY_OF_MONTH, -1)
|
||||
}
|
||||
|
||||
return HabitListState(
|
||||
habitStateLists,
|
||||
weekDayStrings = weekDayStrings,
|
||||
dateStrings = dateStrings
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IndividualHabitListCardPresenter {
|
||||
companion object {
|
||||
fun buildState(
|
||||
habit: Habit,
|
||||
theme: Theme,
|
||||
maxDays: Int
|
||||
): IndividualHabitListState {
|
||||
|
||||
val state = OverviewCardPresenter.buildState(
|
||||
habit = habit,
|
||||
theme = theme
|
||||
)
|
||||
val color = theme.color(habit.color)
|
||||
val score = state.scoreMonthDiff
|
||||
val isNumerical = habit.isNumerical
|
||||
val today = DateUtils.getTodayWithOffset()
|
||||
val values = ArrayList<Int>()
|
||||
for (index in 0..maxDays) {
|
||||
values.add(habit.computedEntries.get(today.minus(index)).value)
|
||||
}
|
||||
val targetValue =habit.targetValue
|
||||
val targetType = habit.targetType
|
||||
val name = habit.name
|
||||
val unit = habit.unit
|
||||
|
||||
return IndividualHabitListState(
|
||||
color = color,
|
||||
score = score,
|
||||
isNumerical = isNumerical,
|
||||
values = values,
|
||||
targetValue = targetValue,
|
||||
targetType = targetType,
|
||||
name = name,
|
||||
unit = unit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue