mirror of https://github.com/iSoron/uhabits.git
parent
228be95f9c
commit
98f9693cff
@ -1,71 +0,0 @@
|
||||
/*
|
||||
* 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.os.*;
|
||||
|
||||
import androidx.customview.view.*;
|
||||
|
||||
public class BundleSavedState extends AbsSavedState
|
||||
{
|
||||
public static final Parcelable.Creator<BundleSavedState> CREATOR =
|
||||
new ClassLoaderCreator<BundleSavedState>()
|
||||
{
|
||||
@Override
|
||||
public BundleSavedState createFromParcel(Parcel source,
|
||||
ClassLoader loader)
|
||||
{
|
||||
return new BundleSavedState(source, loader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BundleSavedState createFromParcel(Parcel source)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BundleSavedState[] newArray(int size)
|
||||
{
|
||||
return new BundleSavedState[size];
|
||||
}
|
||||
};
|
||||
|
||||
public final Bundle bundle;
|
||||
|
||||
public BundleSavedState(Parcelable superState, Bundle bundle)
|
||||
{
|
||||
super(superState);
|
||||
this.bundle = bundle;
|
||||
}
|
||||
|
||||
public BundleSavedState(Parcel source, ClassLoader loader)
|
||||
{
|
||||
super(source, loader);
|
||||
this.bundle = source.readBundle(loader);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel out, int flags)
|
||||
{
|
||||
super.writeToParcel(out, flags);
|
||||
out.writeBundle(bundle);
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.os.Parcelable.ClassLoaderCreator
|
||||
import androidx.customview.view.AbsSavedState
|
||||
|
||||
class BundleSavedState : AbsSavedState {
|
||||
val bundle: Bundle?
|
||||
|
||||
constructor(superState: Parcelable?, bundle: Bundle?) : super(superState!!) {
|
||||
this.bundle = bundle
|
||||
}
|
||||
|
||||
constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
|
||||
bundle = source.readBundle(loader)
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
out.writeBundle(bundle)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val CREATOR: Parcelable.Creator<BundleSavedState> =
|
||||
object : ClassLoaderCreator<BundleSavedState> {
|
||||
override fun createFromParcel(
|
||||
source: Parcel,
|
||||
loader: ClassLoader
|
||||
): BundleSavedState {
|
||||
return BundleSavedState(source, loader)
|
||||
}
|
||||
|
||||
override fun createFromParcel(source: Parcel): BundleSavedState? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<BundleSavedState?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,347 +0,0 @@
|
||||
/*
|
||||
* 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.*;
|
||||
import android.graphics.*;
|
||||
import android.util.*;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.core.models.*;
|
||||
import org.isoron.uhabits.core.utils.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
|
||||
import java.text.*;
|
||||
import java.util.*;
|
||||
|
||||
public class FrequencyChart extends ScrollableChart
|
||||
{
|
||||
private Paint pGrid;
|
||||
|
||||
private float em;
|
||||
|
||||
private SimpleDateFormat dfMonth;
|
||||
|
||||
private SimpleDateFormat dfYear;
|
||||
|
||||
private Paint pText, pGraph;
|
||||
|
||||
private RectF rect, prevRect;
|
||||
|
||||
private int baseSize;
|
||||
|
||||
private int paddingTop;
|
||||
|
||||
private float columnWidth;
|
||||
|
||||
private int columnHeight;
|
||||
|
||||
private int nColumns;
|
||||
|
||||
private int textColor;
|
||||
|
||||
private int gridColor;
|
||||
|
||||
private int[] colors;
|
||||
|
||||
private int primaryColor;
|
||||
|
||||
private boolean isBackgroundTransparent;
|
||||
|
||||
@NonNull
|
||||
private HashMap<Timestamp, Integer[]> frequency;
|
||||
|
||||
private int maxFreq;
|
||||
|
||||
private int firstWeekday = Calendar.SUNDAY;
|
||||
|
||||
public FrequencyChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public FrequencyChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
this.frequency = new HashMap<>();
|
||||
init();
|
||||
}
|
||||
|
||||
public void setColor(int color)
|
||||
{
|
||||
this.primaryColor = color;
|
||||
initColors();
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setFrequency(HashMap<Timestamp, Integer[]> frequency)
|
||||
{
|
||||
this.frequency = frequency;
|
||||
maxFreq = getMaxFreq(frequency);
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setFirstWeekday(int firstWeekday)
|
||||
{
|
||||
this.firstWeekday = firstWeekday;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private int getMaxFreq(HashMap<Timestamp, Integer[]> frequency)
|
||||
{
|
||||
int maxValue = 1;
|
||||
|
||||
for (Integer[] values : frequency.values())
|
||||
for (Integer value : values)
|
||||
maxValue = Math.max(value, maxValue);
|
||||
|
||||
return maxValue;
|
||||
}
|
||||
|
||||
public void setIsBackgroundTransparent(boolean isBackgroundTransparent)
|
||||
{
|
||||
this.isBackgroundTransparent = isBackgroundTransparent;
|
||||
initColors();
|
||||
}
|
||||
|
||||
protected void initPaints()
|
||||
{
|
||||
pText = new Paint();
|
||||
pText.setAntiAlias(true);
|
||||
|
||||
pGraph = new Paint();
|
||||
pGraph.setTextAlign(Paint.Align.CENTER);
|
||||
pGraph.setAntiAlias(true);
|
||||
|
||||
pGrid = new Paint();
|
||||
pGrid.setAntiAlias(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
|
||||
rect.set(0, 0, nColumns * columnWidth, columnHeight);
|
||||
rect.offset(0, paddingTop);
|
||||
|
||||
drawGrid(canvas, rect);
|
||||
|
||||
pText.setTextAlign(Paint.Align.CENTER);
|
||||
pText.setColor(textColor);
|
||||
pGraph.setColor(primaryColor);
|
||||
prevRect.setEmpty();
|
||||
|
||||
GregorianCalendar currentDate = DateUtils.getStartOfTodayCalendarWithOffset();
|
||||
currentDate.set(Calendar.DAY_OF_MONTH, 1);
|
||||
currentDate.add(Calendar.MONTH, -nColumns + 2 - getDataOffset());
|
||||
|
||||
for (int i = 0; i < nColumns - 1; i++)
|
||||
{
|
||||
rect.set(0, 0, columnWidth, columnHeight);
|
||||
rect.offset(i * columnWidth, 0);
|
||||
|
||||
drawColumn(canvas, rect, currentDate);
|
||||
currentDate.add(Calendar.MONTH, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
|
||||
{
|
||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int height = MeasureSpec.getSize(heightMeasureSpec);
|
||||
setMeasuredDimension(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int width,
|
||||
int height,
|
||||
int oldWidth,
|
||||
int oldHeight)
|
||||
{
|
||||
if (height < 9) height = 200;
|
||||
|
||||
baseSize = height / 8;
|
||||
setScrollerBucketSize(baseSize);
|
||||
|
||||
pText.setTextSize(baseSize * 0.4f);
|
||||
pGraph.setTextSize(baseSize * 0.4f);
|
||||
pGraph.setStrokeWidth(baseSize * 0.1f);
|
||||
pGrid.setStrokeWidth(baseSize * 0.05f);
|
||||
em = pText.getFontSpacing();
|
||||
|
||||
columnWidth = baseSize;
|
||||
columnWidth = Math.max(columnWidth, getMaxMonthWidth() * 1.2f);
|
||||
|
||||
columnHeight = 8 * baseSize;
|
||||
nColumns = (int) (width / columnWidth);
|
||||
paddingTop = 0;
|
||||
}
|
||||
|
||||
private void drawColumn(Canvas canvas, RectF rect, GregorianCalendar date)
|
||||
{
|
||||
Integer[] values = frequency.get(new Timestamp(date));
|
||||
float rowHeight = rect.height() / 8.0f;
|
||||
prevRect.set(rect);
|
||||
|
||||
Integer[] localeWeekdayList = DateUtils.getWeekdaySequence(firstWeekday);
|
||||
for (int j = 0; j < localeWeekdayList.length; j++)
|
||||
{
|
||||
rect.set(0, 0, baseSize, baseSize);
|
||||
rect.offset(prevRect.left, prevRect.top + baseSize * j);
|
||||
|
||||
int i = localeWeekdayList[j] % 7;
|
||||
if (values != null) drawMarker(canvas, rect, values[i]);
|
||||
|
||||
rect.offset(0, rowHeight);
|
||||
}
|
||||
|
||||
drawFooter(canvas, rect, date);
|
||||
}
|
||||
|
||||
private void drawFooter(Canvas canvas, RectF rect, GregorianCalendar date)
|
||||
{
|
||||
Date time = date.getTime();
|
||||
|
||||
canvas.drawText(dfMonth.format(time), rect.centerX(),
|
||||
rect.centerY() - 0.1f * em, pText);
|
||||
|
||||
if (date.get(Calendar.MONTH) == 1)
|
||||
canvas.drawText(dfYear.format(time), rect.centerX(),
|
||||
rect.centerY() + 0.9f * em, pText);
|
||||
}
|
||||
|
||||
private void drawGrid(Canvas canvas, RectF rGrid)
|
||||
{
|
||||
int nRows = 7;
|
||||
float rowHeight = rGrid.height() / (nRows + 1);
|
||||
|
||||
pText.setTextAlign(Paint.Align.LEFT);
|
||||
pText.setColor(textColor);
|
||||
pGrid.setColor(gridColor);
|
||||
|
||||
for (String day : DateUtils.getShortWeekdayNames(firstWeekday))
|
||||
{
|
||||
canvas.drawText(day, rGrid.right - columnWidth,
|
||||
rGrid.top + rowHeight / 2 + 0.25f * em, pText);
|
||||
|
||||
pGrid.setStrokeWidth(1f);
|
||||
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top,
|
||||
pGrid);
|
||||
|
||||
rGrid.offset(0, rowHeight);
|
||||
}
|
||||
|
||||
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid);
|
||||
}
|
||||
|
||||
private void drawMarker(Canvas canvas, RectF rect, Integer value)
|
||||
{
|
||||
float padding = rect.height() * 0.2f;
|
||||
// maximal allowed mark radius
|
||||
float maxRadius = (rect.height() - 2 * padding) / 2.0f;
|
||||
// the real mark radius is scaled down by a factor depending on the maximal frequency
|
||||
float scale = 1.0f/maxFreq * value;
|
||||
float radius = maxRadius * scale;
|
||||
|
||||
int colorIndex = Math.min(colors.length - 1, Math.round((colors.length - 1) * scale));
|
||||
pGraph.setColor(colors[colorIndex]);
|
||||
canvas.drawCircle(rect.centerX(), rect.centerY(), radius, pGraph);
|
||||
}
|
||||
|
||||
private float getMaxMonthWidth()
|
||||
{
|
||||
float maxMonthWidth = 0;
|
||||
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
day.set(Calendar.MONTH, i);
|
||||
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
|
||||
maxMonthWidth = Math.max(maxMonthWidth, monthWidth);
|
||||
}
|
||||
|
||||
return maxMonthWidth;
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
initPaints();
|
||||
initColors();
|
||||
initDateFormats();
|
||||
initRects();
|
||||
}
|
||||
|
||||
private void initColors()
|
||||
{
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
textColor = res.getColor(R.attr.mediumContrastTextColor);
|
||||
gridColor = res.getColor(R.attr.lowContrastTextColor);
|
||||
|
||||
colors = new int[4];
|
||||
colors[0] = gridColor;
|
||||
colors[3] = primaryColor;
|
||||
colors[1] = ColorUtils.mixColors(colors[0], colors[3], 0.66f);
|
||||
colors[2] = ColorUtils.mixColors(colors[0], colors[3], 0.33f);
|
||||
}
|
||||
|
||||
private void initDateFormats()
|
||||
{
|
||||
if (isInEditMode())
|
||||
{
|
||||
dfMonth = new SimpleDateFormat("MMM", Locale.getDefault());
|
||||
dfYear = new SimpleDateFormat("yyyy", Locale.getDefault());
|
||||
}
|
||||
else
|
||||
{
|
||||
dfMonth = DateExtensionsKt.toSimpleDataFormat("MMM");
|
||||
dfYear = DateExtensionsKt.toSimpleDataFormat("yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
private void initRects()
|
||||
{
|
||||
rect = new RectF();
|
||||
prevRect = new RectF();
|
||||
}
|
||||
|
||||
public void populateWithRandomData()
|
||||
{
|
||||
GregorianCalendar date = DateUtils.getStartOfTodayCalendar();
|
||||
date.set(Calendar.DAY_OF_MONTH, 1);
|
||||
Random rand = new Random();
|
||||
frequency.clear();
|
||||
|
||||
for (int i = 0; i < 40; i++)
|
||||
{
|
||||
Integer values[] = new Integer[7];
|
||||
for (int j = 0; j < 7; j++)
|
||||
values[j] = rand.nextInt(5);
|
||||
|
||||
frequency.put(new Timestamp(date), values);
|
||||
date.add(Calendar.MONTH, -1);
|
||||
}
|
||||
maxFreq = getMaxFreq(frequency);
|
||||
}
|
||||
}
|
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.models.Timestamp
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getShortWeekdayNames
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendar
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendarWithOffset
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getWeekdaySequence
|
||||
import org.isoron.uhabits.utils.ColorUtils.mixColors
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
import org.isoron.uhabits.utils.toSimpleDataFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.Random
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class FrequencyChart : ScrollableChart {
|
||||
private var pGrid: Paint? = null
|
||||
private var em = 0f
|
||||
private var dfMonth: SimpleDateFormat? = null
|
||||
private var dfYear: SimpleDateFormat? = null
|
||||
private var pText: Paint? = null
|
||||
private var pGraph: Paint? = null
|
||||
private var rect: RectF? = null
|
||||
private var prevRect: RectF? = null
|
||||
private var baseSize = 0
|
||||
private var internalPaddingTop = 0
|
||||
private var columnWidth = 0f
|
||||
private var columnHeight = 0
|
||||
private var nColumns = 0
|
||||
private var textColor = 0
|
||||
private var gridColor = 0
|
||||
private lateinit var colors: IntArray
|
||||
private var primaryColor = 0
|
||||
private var isBackgroundTransparent = false
|
||||
private lateinit var frequency: HashMap<Timestamp, Array<Int>>
|
||||
private var maxFreq = 0
|
||||
private var firstWeekday = Calendar.SUNDAY
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
frequency = HashMap()
|
||||
init()
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
primaryColor = color
|
||||
initColors()
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setFrequency(frequency: java.util.HashMap<Timestamp, Array<Int>>) {
|
||||
this.frequency = frequency
|
||||
maxFreq = getMaxFreq(frequency)
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setFirstWeekday(firstWeekday: Int) {
|
||||
this.firstWeekday = firstWeekday
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
private fun getMaxFreq(frequency: HashMap<Timestamp, Array<Int>>): Int {
|
||||
var maxValue = 1
|
||||
for (values in frequency.values) for (value in values) maxValue = Math.max(
|
||||
value!!,
|
||||
maxValue
|
||||
)
|
||||
return maxValue
|
||||
}
|
||||
|
||||
fun setIsBackgroundTransparent(isBackgroundTransparent: Boolean) {
|
||||
this.isBackgroundTransparent = isBackgroundTransparent
|
||||
initColors()
|
||||
}
|
||||
|
||||
protected fun initPaints() {
|
||||
pText = Paint()
|
||||
pText!!.isAntiAlias = true
|
||||
pGraph = Paint()
|
||||
pGraph!!.textAlign = Paint.Align.CENTER
|
||||
pGraph!!.isAntiAlias = true
|
||||
pGrid = Paint()
|
||||
pGrid!!.isAntiAlias = true
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
rect!![0f, 0f, nColumns * columnWidth] = columnHeight.toFloat()
|
||||
rect!!.offset(0f, internalPaddingTop.toFloat())
|
||||
drawGrid(canvas, rect)
|
||||
pText!!.textAlign = Paint.Align.CENTER
|
||||
pText!!.color = textColor
|
||||
pGraph!!.color = primaryColor
|
||||
prevRect!!.setEmpty()
|
||||
val currentDate: GregorianCalendar =
|
||||
getStartOfTodayCalendarWithOffset()
|
||||
currentDate[Calendar.DAY_OF_MONTH] = 1
|
||||
currentDate.add(Calendar.MONTH, -nColumns + 2 - dataOffset)
|
||||
for (i in 0 until nColumns - 1) {
|
||||
rect!![0f, 0f, columnWidth] = columnHeight.toFloat()
|
||||
rect!!.offset(i * columnWidth, 0f)
|
||||
drawColumn(canvas, rect, currentDate)
|
||||
currentDate.add(Calendar.MONTH, 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(
|
||||
width: Int,
|
||||
height: Int,
|
||||
oldWidth: Int,
|
||||
oldHeight: Int
|
||||
) {
|
||||
var height = height
|
||||
if (height < 9) height = 200
|
||||
baseSize = height / 8
|
||||
setScrollerBucketSize(baseSize)
|
||||
pText!!.textSize = baseSize * 0.4f
|
||||
pGraph!!.textSize = baseSize * 0.4f
|
||||
pGraph!!.strokeWidth = baseSize * 0.1f
|
||||
pGrid!!.strokeWidth = baseSize * 0.05f
|
||||
em = pText!!.fontSpacing
|
||||
columnWidth = baseSize.toFloat()
|
||||
columnWidth = Math.max(columnWidth, maxMonthWidth * 1.2f)
|
||||
columnHeight = 8 * baseSize
|
||||
nColumns = (width / columnWidth).toInt()
|
||||
internalPaddingTop = 0
|
||||
}
|
||||
|
||||
private fun drawColumn(canvas: Canvas, rect: RectF?, date: GregorianCalendar) {
|
||||
val values = frequency[Timestamp(date)]
|
||||
val rowHeight = rect!!.height() / 8.0f
|
||||
prevRect!!.set(rect)
|
||||
val localeWeekdayList: Array<Int> = getWeekdaySequence(firstWeekday)
|
||||
for (j in localeWeekdayList.indices) {
|
||||
rect[0f, 0f, baseSize.toFloat()] = baseSize.toFloat()
|
||||
rect.offset(prevRect!!.left, prevRect!!.top + baseSize * j)
|
||||
val i = localeWeekdayList[j] % 7
|
||||
if (values != null) drawMarker(canvas, rect, values[i])
|
||||
rect.offset(0f, rowHeight)
|
||||
}
|
||||
drawFooter(canvas, rect, date)
|
||||
}
|
||||
|
||||
private fun drawFooter(canvas: Canvas, rect: RectF?, date: GregorianCalendar) {
|
||||
val time = date.time
|
||||
canvas.drawText(
|
||||
dfMonth!!.format(time),
|
||||
rect!!.centerX(),
|
||||
rect.centerY() - 0.1f * em,
|
||||
pText!!
|
||||
)
|
||||
if (date[Calendar.MONTH] == 1) canvas.drawText(
|
||||
dfYear!!.format(time),
|
||||
rect.centerX(),
|
||||
rect.centerY() + 0.9f * em,
|
||||
pText!!
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas, rGrid: RectF?) {
|
||||
val nRows = 7
|
||||
val rowHeight = rGrid!!.height() / (nRows + 1)
|
||||
pText!!.textAlign = Paint.Align.LEFT
|
||||
pText!!.color = textColor
|
||||
pGrid!!.color = gridColor
|
||||
for (day in getShortWeekdayNames(firstWeekday)) {
|
||||
canvas.drawText(
|
||||
day,
|
||||
rGrid.right - columnWidth,
|
||||
rGrid.top + rowHeight / 2 + 0.25f * em,
|
||||
pText!!
|
||||
)
|
||||
pGrid!!.strokeWidth = 1f
|
||||
canvas.drawLine(
|
||||
rGrid.left,
|
||||
rGrid.top,
|
||||
rGrid.right,
|
||||
rGrid.top,
|
||||
pGrid!!
|
||||
)
|
||||
rGrid.offset(0f, rowHeight)
|
||||
}
|
||||
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid!!)
|
||||
}
|
||||
|
||||
private fun drawMarker(canvas: Canvas, rect: RectF?, value: Int?) {
|
||||
val padding = rect!!.height() * 0.2f
|
||||
// maximal allowed mark radius
|
||||
val maxRadius = (rect.height() - 2 * padding) / 2.0f
|
||||
// the real mark radius is scaled down by a factor depending on the maximal frequency
|
||||
val scale = 1.0f / maxFreq * value!!
|
||||
val radius = maxRadius * scale
|
||||
val colorIndex = Math.min(colors.size - 1, Math.round((colors.size - 1) * scale))
|
||||
pGraph!!.color = colors[colorIndex]
|
||||
canvas.drawCircle(rect.centerX(), rect.centerY(), radius, pGraph!!)
|
||||
}
|
||||
|
||||
private val maxMonthWidth: Float
|
||||
private get() {
|
||||
var maxMonthWidth = 0f
|
||||
val day: GregorianCalendar =
|
||||
getStartOfTodayCalendarWithOffset()
|
||||
for (i in 0..11) {
|
||||
day[Calendar.MONTH] = i
|
||||
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
|
||||
maxMonthWidth = Math.max(maxMonthWidth, monthWidth)
|
||||
}
|
||||
return maxMonthWidth
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
initPaints()
|
||||
initColors()
|
||||
initDateFormats()
|
||||
initRects()
|
||||
}
|
||||
|
||||
private fun initColors() {
|
||||
val res = StyledResources(context)
|
||||
textColor = res.getColor(R.attr.mediumContrastTextColor)
|
||||
gridColor = res.getColor(R.attr.lowContrastTextColor)
|
||||
colors = IntArray(4)
|
||||
colors[0] = gridColor
|
||||
colors[3] = primaryColor
|
||||
colors[1] = mixColors(colors[0], colors[3], 0.66f)
|
||||
colors[2] = mixColors(colors[0], colors[3], 0.33f)
|
||||
}
|
||||
|
||||
private fun initDateFormats() {
|
||||
if (isInEditMode) {
|
||||
dfMonth = SimpleDateFormat("MMM", Locale.getDefault())
|
||||
dfYear = SimpleDateFormat("yyyy", Locale.getDefault())
|
||||
} else {
|
||||
dfMonth = "MMM".toSimpleDataFormat()
|
||||
dfYear = "yyyy".toSimpleDataFormat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initRects() {
|
||||
rect = RectF()
|
||||
prevRect = RectF()
|
||||
}
|
||||
|
||||
fun populateWithRandomData() {
|
||||
val date: GregorianCalendar = getStartOfTodayCalendar()
|
||||
date[Calendar.DAY_OF_MONTH] = 1
|
||||
val rand = Random()
|
||||
frequency.clear()
|
||||
for (i in 0..39) {
|
||||
val values = IntArray(7) { rand.nextInt(5) }.toTypedArray()
|
||||
frequency[Timestamp(date)] = values
|
||||
date.add(Calendar.MONTH, -1)
|
||||
}
|
||||
maxFreq = getMaxFreq(frequency)
|
||||
}
|
||||
}
|
@ -1,272 +0,0 @@
|
||||
/*
|
||||
* 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.*;
|
||||
import android.graphics.*;
|
||||
import android.text.*;
|
||||
import android.util.*;
|
||||
import android.view.*;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
|
||||
import static org.isoron.uhabits.utils.AttributeSetUtils.*;
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
|
||||
public class RingView extends View
|
||||
{
|
||||
public static final PorterDuffXfermode XFERMODE_CLEAR =
|
||||
new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
|
||||
|
||||
private int color;
|
||||
|
||||
private float precision;
|
||||
|
||||
private float percentage;
|
||||
|
||||
private int diameter;
|
||||
|
||||
private float thickness;
|
||||
|
||||
private RectF rect;
|
||||
|
||||
private TextPaint pRing;
|
||||
|
||||
private Integer backgroundColor;
|
||||
|
||||
private Integer inactiveColor;
|
||||
|
||||
private float em;
|
||||
|
||||
private String text;
|
||||
|
||||
private float textSize;
|
||||
|
||||
private boolean enableFontAwesome;
|
||||
|
||||
@Nullable
|
||||
private Bitmap drawingCache;
|
||||
|
||||
private Canvas cacheCanvas;
|
||||
|
||||
private boolean isTransparencyEnabled;
|
||||
|
||||
public RingView(Context context)
|
||||
{
|
||||
super(context);
|
||||
|
||||
percentage = 0.0f;
|
||||
precision = 0.01f;
|
||||
color = PaletteUtils.getAndroidTestColor(0);
|
||||
thickness = dpToPixels(getContext(), 2);
|
||||
text = "";
|
||||
textSize = getDimension(context, R.dimen.smallTextSize);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
public RingView(Context ctx, AttributeSet attrs)
|
||||
{
|
||||
super(ctx, attrs);
|
||||
|
||||
percentage = getFloatAttribute(ctx, attrs, "percentage", 0);
|
||||
precision = getFloatAttribute(ctx, attrs, "precision", 0.01f);
|
||||
|
||||
color = getColorAttribute(ctx, attrs, "color", 0);
|
||||
backgroundColor = getColorAttribute(ctx, attrs, "backgroundColor", null);
|
||||
inactiveColor = getColorAttribute(ctx, attrs, "inactiveColor", null);
|
||||
|
||||
thickness = getFloatAttribute(ctx, attrs, "thickness", 0);
|
||||
thickness = dpToPixels(ctx, thickness);
|
||||
|
||||
float defaultTextSize = getDimension(ctx, R.dimen.smallTextSize);
|
||||
textSize = getFloatAttribute(ctx, attrs, "textSize", defaultTextSize);
|
||||
textSize = spToPixels(ctx, textSize);
|
||||
text = getAttribute(ctx, attrs, "text", "");
|
||||
|
||||
enableFontAwesome =
|
||||
getBooleanAttribute(ctx, attrs, "enableFontAwesome", false);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBackgroundColor(int backgroundColor)
|
||||
{
|
||||
this.backgroundColor = backgroundColor;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setColor(int color)
|
||||
{
|
||||
this.color = color;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public int getColor()
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setIsTransparencyEnabled(boolean isTransparencyEnabled)
|
||||
{
|
||||
this.isTransparencyEnabled = isTransparencyEnabled;
|
||||
}
|
||||
|
||||
public void setPercentage(float percentage)
|
||||
{
|
||||
this.percentage = percentage;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setPrecision(float precision)
|
||||
{
|
||||
this.precision = precision;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setText(String text)
|
||||
{
|
||||
this.text = text;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setTextSize(float textSize)
|
||||
{
|
||||
this.textSize = textSize;
|
||||
}
|
||||
|
||||
public void setThickness(float thickness)
|
||||
{
|
||||
this.thickness = thickness;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
Canvas activeCanvas;
|
||||
|
||||
if (isTransparencyEnabled)
|
||||
{
|
||||
if (drawingCache == null) reallocateCache();
|
||||
activeCanvas = cacheCanvas;
|
||||
drawingCache.eraseColor(Color.TRANSPARENT);
|
||||
}
|
||||
else
|
||||
{
|
||||
activeCanvas = canvas;
|
||||
}
|
||||
|
||||
pRing.setColor(color);
|
||||
rect.set(0, 0, diameter, diameter);
|
||||
|
||||
float angle = 360 * Math.round(percentage / precision) * precision;
|
||||
|
||||
activeCanvas.drawArc(rect, -90, angle, true, pRing);
|
||||
|
||||
pRing.setColor(inactiveColor);
|
||||
activeCanvas.drawArc(rect, angle - 90, 360 - angle, true, pRing);
|
||||
|
||||
if (thickness > 0)
|
||||
{
|
||||
if (isTransparencyEnabled) pRing.setXfermode(XFERMODE_CLEAR);
|
||||
else pRing.setColor(backgroundColor);
|
||||
|
||||
rect.inset(thickness, thickness);
|
||||
activeCanvas.drawArc(rect, 0, 360, true, pRing);
|
||||
pRing.setXfermode(null);
|
||||
|
||||
pRing.setColor(color);
|
||||
pRing.setTextSize(textSize);
|
||||
if (enableFontAwesome)
|
||||
pRing.setTypeface(getFontAwesome(getContext()));
|
||||
activeCanvas.drawText(text, rect.centerX(),
|
||||
rect.centerY() + 0.4f * em, pRing);
|
||||
}
|
||||
|
||||
if (activeCanvas != canvas) canvas.drawBitmap(drawingCache, 0, 0, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
|
||||
{
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
|
||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int height = MeasureSpec.getSize(heightMeasureSpec);
|
||||
diameter = Math.min(height, width);
|
||||
|
||||
pRing.setTextSize(textSize);
|
||||
em = pRing.measureText("M");
|
||||
|
||||
setMeasuredDimension(diameter, diameter);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh)
|
||||
{
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
|
||||
if (isTransparencyEnabled) reallocateCache();
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
pRing = new TextPaint();
|
||||
pRing.setAntiAlias(true);
|
||||
pRing.setColor(color);
|
||||
pRing.setTextAlign(Paint.Align.CENTER);
|
||||
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
|
||||
if (backgroundColor == null)
|
||||
backgroundColor = res.getColor(R.attr.cardBgColor);
|
||||
|
||||
if (inactiveColor == null)
|
||||
inactiveColor = res.getColor(R.attr.highContrastTextColor);
|
||||
|
||||
inactiveColor = ColorUtils.setAlpha(inactiveColor, 0.1f);
|
||||
|
||||
rect = new RectF();
|
||||
}
|
||||
|
||||
private void reallocateCache()
|
||||
{
|
||||
if (drawingCache != null) drawingCache.recycle();
|
||||
drawingCache =
|
||||
Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888);
|
||||
cacheCanvas = new Canvas(drawingCache);
|
||||
}
|
||||
|
||||
public float getPercentage()
|
||||
{
|
||||
return percentage;
|
||||
}
|
||||
|
||||
public float getPrecision()
|
||||
{
|
||||
return precision;
|
||||
}
|
||||
}
|
@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.utils.AttributeSetUtils.getAttribute
|
||||
import org.isoron.uhabits.utils.AttributeSetUtils.getBooleanAttribute
|
||||
import org.isoron.uhabits.utils.AttributeSetUtils.getColorAttribute
|
||||
import org.isoron.uhabits.utils.AttributeSetUtils.getFloatAttribute
|
||||
import org.isoron.uhabits.utils.ColorUtils.setAlpha
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.spToPixels
|
||||
import org.isoron.uhabits.utils.PaletteUtils.getAndroidTestColor
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
|
||||
class RingView : View {
|
||||
private var color: Int
|
||||
private var precision: Float
|
||||
private var percentage: Float
|
||||
private var diameter = 0
|
||||
private var thickness: Float
|
||||
private var rect: RectF? = null
|
||||
private var pRing: TextPaint? = null
|
||||
private var backgroundColor: Int? = null
|
||||
private var inactiveColor: Int? = null
|
||||
private var em = 0f
|
||||
private var text: String?
|
||||
private var textSize: Float
|
||||
private var enableFontAwesome = false
|
||||
private var internalDrawingCache: Bitmap? = null
|
||||
private var cacheCanvas: Canvas? = null
|
||||
private var isTransparencyEnabled = false
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
percentage = 0.0f
|
||||
precision = 0.01f
|
||||
color = getAndroidTestColor(0)
|
||||
thickness = dpToPixels(getContext(), 2f)
|
||||
text = ""
|
||||
textSize = getDimension(context!!, R.dimen.smallTextSize)
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(ctx: Context?, attrs: AttributeSet?) : super(ctx, attrs) {
|
||||
percentage = getFloatAttribute(ctx!!, attrs!!, "percentage", 0f)
|
||||
precision = getFloatAttribute(ctx, attrs, "precision", 0.01f)
|
||||
color = getColorAttribute(ctx, attrs, "color", 0)!!
|
||||
backgroundColor = getColorAttribute(ctx, attrs, "backgroundColor", null)
|
||||
inactiveColor = getColorAttribute(ctx, attrs, "inactiveColor", null)
|
||||
thickness = getFloatAttribute(ctx, attrs, "thickness", 0f)
|
||||
thickness = dpToPixels(ctx, thickness)
|
||||
val defaultTextSize = getDimension(ctx, R.dimen.smallTextSize)
|
||||
textSize = getFloatAttribute(ctx, attrs, "textSize", defaultTextSize)
|
||||
textSize = spToPixels(ctx, textSize)
|
||||
text = getAttribute(ctx, attrs, "text", "")
|
||||
enableFontAwesome = getBooleanAttribute(ctx, attrs, "enableFontAwesome", false)
|
||||
init()
|
||||
}
|
||||
|
||||
override fun setBackgroundColor(backgroundColor: Int) {
|
||||
this.backgroundColor = backgroundColor
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
this.color = color
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun getColor(): Int {
|
||||
return color
|
||||
}
|
||||
|
||||
fun setIsTransparencyEnabled(isTransparencyEnabled: Boolean) {
|
||||
this.isTransparencyEnabled = isTransparencyEnabled
|
||||
}
|
||||
|
||||
fun setPercentage(percentage: Float) {
|
||||
this.percentage = percentage
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setPrecision(precision: Float) {
|
||||
this.precision = precision
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setText(text: String?) {
|
||||
this.text = text
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setTextSize(textSize: Float) {
|
||||
this.textSize = textSize
|
||||
}
|
||||
|
||||
fun setThickness(thickness: Float) {
|
||||
this.thickness = thickness
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val activeCanvas: Canvas?
|
||||
if (isTransparencyEnabled) {
|
||||
if (internalDrawingCache == null) reallocateCache()
|
||||
activeCanvas = cacheCanvas
|
||||
internalDrawingCache!!.eraseColor(Color.TRANSPARENT)
|
||||
} else {
|
||||
activeCanvas = canvas
|
||||
}
|
||||
pRing!!.color = color
|
||||
rect!![0f, 0f, diameter.toFloat()] = diameter.toFloat()
|
||||
val angle = 360 * Math.round(percentage / precision) * precision
|
||||
activeCanvas!!.drawArc(rect!!, -90f, angle, true, pRing!!)
|
||||
pRing!!.color = inactiveColor!!
|
||||
activeCanvas.drawArc(rect!!, angle - 90, 360 - angle, true, pRing!!)
|
||||
if (thickness > 0) {
|
||||
if (isTransparencyEnabled) pRing!!.xfermode = XFERMODE_CLEAR else pRing!!.color =
|
||||
backgroundColor!!
|
||||
rect!!.inset(thickness, thickness)
|
||||
activeCanvas.drawArc(rect!!, 0f, 360f, true, pRing!!)
|
||||
pRing!!.xfermode = null
|
||||
pRing!!.color = color
|
||||
pRing!!.textSize = textSize
|
||||
if (enableFontAwesome) pRing!!.typeface = getFontAwesome(context)
|
||||
activeCanvas.drawText(
|
||||
text!!,
|
||||
rect!!.centerX(),
|
||||
rect!!.centerY() + 0.4f * em,
|
||||
pRing!!
|
||||
)
|
||||
}
|
||||
if (activeCanvas !== canvas) canvas.drawBitmap(internalDrawingCache!!, 0f, 0f, null)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
diameter = Math.min(height, width)
|
||||
pRing!!.textSize = textSize
|
||||
em = pRing!!.measureText("M")
|
||||
setMeasuredDimension(diameter, diameter)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
if (isTransparencyEnabled) reallocateCache()
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
pRing = TextPaint()
|
||||
pRing!!.isAntiAlias = true
|
||||
pRing!!.color = color
|
||||
pRing!!.textAlign = Paint.Align.CENTER
|
||||
val res = StyledResources(context)
|
||||
if (backgroundColor == null) backgroundColor = res.getColor(R.attr.cardBgColor)
|
||||
if (inactiveColor == null) inactiveColor = res.getColor(R.attr.highContrastTextColor)
|
||||
inactiveColor = setAlpha(inactiveColor!!, 0.1f)
|
||||
rect = RectF()
|
||||
}
|
||||
|
||||
private fun reallocateCache() {
|
||||
if (internalDrawingCache != null) internalDrawingCache!!.recycle()
|
||||
val newDrawingCache = Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888)
|
||||
internalDrawingCache = newDrawingCache
|
||||
cacheCanvas = Canvas(newDrawingCache)
|
||||
}
|
||||
|
||||
fun getPercentage(): Float {
|
||||
return percentage
|
||||
}
|
||||
|
||||
fun getPrecision(): Float {
|
||||
return precision
|
||||
}
|
||||
|
||||
companion object {
|
||||
val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
|
||||
}
|
||||
}
|
@ -1,452 +0,0 @@
|
||||
/*
|
||||
* 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.*;
|
||||
import android.graphics.*;
|
||||
import android.util.*;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.core.models.*;
|
||||
import org.isoron.uhabits.core.utils.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
|
||||
import java.text.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
|
||||
public class ScoreChart extends ScrollableChart
|
||||
{
|
||||
private static final PorterDuffXfermode XFERMODE_CLEAR =
|
||||
new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
|
||||
|
||||
private static final PorterDuffXfermode XFERMODE_SRC =
|
||||
new PorterDuffXfermode(PorterDuff.Mode.SRC);
|
||||
|
||||
private Paint pGrid;
|
||||
|
||||
private float em;
|
||||
|
||||
private SimpleDateFormat dfMonth;
|
||||
|
||||
private SimpleDateFormat dfDay;
|
||||
|
||||
private SimpleDateFormat dfYear;
|
||||
|
||||
private Paint pText, pGraph;
|
||||
|
||||
private RectF rect, prevRect;
|
||||
|
||||
private int baseSize;
|
||||
|
||||
private int paddingTop;
|
||||
|
||||
private float columnWidth;
|
||||
|
||||
private int columnHeight;
|
||||
|
||||
private int nColumns;
|
||||
|
||||
private int textColor;
|
||||
|
||||
private int gridColor;
|
||||
|
||||
@Nullable
|
||||
private List<Score> scores;
|
||||
|
||||
private int primaryColor;
|
||||
|
||||
@Deprecated
|
||||
private int bucketSize = 7;
|
||||
|
||||
private int backgroundColor;
|
||||
|
||||
private Bitmap drawingCache;
|
||||
|
||||
private Canvas cacheCanvas;
|
||||
|
||||
private boolean isTransparencyEnabled;
|
||||
|
||||
private int skipYear = 0;
|
||||
|
||||
private String previousYearText;
|
||||
|
||||
private String previousMonthText;
|
||||
|
||||
public ScoreChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ScoreChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public void populateWithRandomData()
|
||||
{
|
||||
Random random = new Random();
|
||||
scores = new LinkedList<>();
|
||||
|
||||
double previous = 0.5f;
|
||||
Timestamp timestamp = DateUtils.getToday();
|
||||
|
||||
for (int i = 1; i < 100; i++)
|
||||
{
|
||||
double step = 0.1f;
|
||||
double current = previous + random.nextDouble() * step * 2 - step;
|
||||
current = Math.max(0, Math.min(1.0f, current));
|
||||
scores.add(new Score(timestamp.minus(i), current));
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
public void setBucketSize(int bucketSize)
|
||||
{
|
||||
this.bucketSize = bucketSize;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setIsTransparencyEnabled(boolean enabled)
|
||||
{
|
||||
this.isTransparencyEnabled = enabled;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setColor(int primaryColor)
|
||||
{
|
||||
this.primaryColor = primaryColor;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setScores(@NonNull List<Score> scores)
|
||||
{
|
||||
this.scores = scores;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
Canvas activeCanvas;
|
||||
|
||||
if (isTransparencyEnabled)
|
||||
{
|
||||
if (drawingCache == null) initCache(getWidth(), getHeight());
|
||||
|
||||
activeCanvas = cacheCanvas;
|
||||
drawingCache.eraseColor(Color.TRANSPARENT);
|
||||
}
|
||||
else
|
||||
{
|
||||
activeCanvas = canvas;
|
||||
}
|
||||
|
||||
if (scores == null) return;
|
||||
|
||||
rect.set(0, 0, nColumns * columnWidth, columnHeight);
|
||||
rect.offset(0, paddingTop);
|
||||
|
||||
drawGrid(activeCanvas, rect);
|
||||
|
||||
pText.setColor(textColor);
|
||||
pGraph.setColor(primaryColor);
|
||||
prevRect.setEmpty();
|
||||
|
||||
previousMonthText = "";
|
||||
previousYearText = "";
|
||||
skipYear = 0;
|
||||
|
||||
for (int k = 0; k < nColumns; k++)
|
||||
{
|
||||
int offset = nColumns - k - 1 + getDataOffset();
|
||||
if (offset >= scores.size()) continue;
|
||||
|
||||
double score = scores.get(offset).getValue();
|
||||
Timestamp timestamp = scores.get(offset).getTimestamp();
|
||||
|
||||
int height = (int) (columnHeight * score);
|
||||
|
||||
rect.set(0, 0, baseSize, baseSize);
|
||||
rect.offset(k * columnWidth + (columnWidth - baseSize) / 2,
|
||||
paddingTop + columnHeight - height - baseSize / 2);
|
||||
|
||||
if (!prevRect.isEmpty())
|
||||
{
|
||||
drawLine(activeCanvas, prevRect, rect);
|
||||
drawMarker(activeCanvas, prevRect);
|
||||
}
|
||||
|
||||
if (k == nColumns - 1) drawMarker(activeCanvas, rect);
|
||||
|
||||
prevRect.set(rect);
|
||||
rect.set(0, 0, columnWidth, columnHeight);
|
||||
rect.offset(k * columnWidth, paddingTop);
|
||||
|
||||
drawFooter(activeCanvas, rect, timestamp);
|
||||
}
|
||||
|
||||
if (activeCanvas != canvas) canvas.drawBitmap(drawingCache, 0, 0, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
|
||||
{
|
||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int height = MeasureSpec.getSize(heightMeasureSpec);
|
||||
setMeasuredDimension(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int width,
|
||||
int height,
|
||||
int oldWidth,
|
||||
int oldHeight)
|
||||
{
|
||||
if (height < 9) height = 200;
|
||||
|
||||
float maxTextSize = getDimension(getContext(), R.dimen.tinyTextSize);
|
||||
float textSize = height * 0.06f;
|
||||
pText.setTextSize(Math.min(textSize, maxTextSize));
|
||||
em = pText.getFontSpacing();
|
||||
|
||||
int footerHeight = (int) (3 * em);
|
||||
paddingTop = (int) (em);
|
||||
|
||||
baseSize = (height - footerHeight - paddingTop) / 8;
|
||||
columnWidth = baseSize;
|
||||
columnWidth = Math.max(columnWidth, getMaxDayWidth() * 1.5f);
|
||||
columnWidth = Math.max(columnWidth, getMaxMonthWidth() * 1.2f);
|
||||
|
||||
nColumns = (int) (width / columnWidth);
|
||||
columnWidth = (float) width / nColumns;
|
||||
setScrollerBucketSize((int) columnWidth);
|
||||
|
||||
columnHeight = 8 * baseSize;
|
||||
|
||||
float minStrokeWidth = dpToPixels(getContext(), 1);
|
||||
pGraph.setTextSize(baseSize * 0.5f);
|
||||
pGraph.setStrokeWidth(baseSize * 0.1f);
|
||||
pGrid.setStrokeWidth(Math.min(minStrokeWidth, baseSize * 0.05f));
|
||||
|
||||
if (isTransparencyEnabled) initCache(width, height);
|
||||
}
|
||||
|
||||
private void drawFooter(Canvas canvas, RectF rect, Timestamp currentDate)
|
||||
{
|
||||
String yearText = dfYear.format(currentDate.toJavaDate());
|
||||
String monthText = dfMonth.format(currentDate.toJavaDate());
|
||||
String dayText = dfDay.format(currentDate.toJavaDate());
|
||||
|
||||
GregorianCalendar calendar = currentDate.toCalendar();
|
||||
|
||||
String text;
|
||||
int year = calendar.get(Calendar.YEAR);
|
||||
|
||||
boolean shouldPrintYear = true;
|
||||
if (yearText.equals(previousYearText)) shouldPrintYear = false;
|
||||
if (bucketSize >= 365 && (year % 2) != 0) shouldPrintYear = false;
|
||||
|
||||
if (skipYear > 0)
|
||||
{
|
||||
skipYear--;
|
||||
shouldPrintYear = false;
|
||||
}
|
||||
|
||||
if (shouldPrintYear)
|
||||
{
|
||||
previousYearText = yearText;
|
||||
previousMonthText = "";
|
||||
|
||||
pText.setTextAlign(Paint.Align.CENTER);
|
||||
canvas.drawText(yearText, rect.centerX(), rect.bottom + em * 2.2f,
|
||||
pText);
|
||||
|
||||
skipYear = 1;
|
||||
}
|
||||
|
||||
if (bucketSize < 365)
|
||||
{
|
||||
if (!monthText.equals(previousMonthText))
|
||||
{
|
||||
previousMonthText = monthText;
|
||||
text = monthText;
|
||||
}
|
||||
else
|
||||
{
|
||||
text = dayText;
|
||||
}
|
||||
|
||||
pText.setTextAlign(Paint.Align.CENTER);
|
||||
canvas.drawText(text, rect.centerX(), rect.bottom + em * 1.2f,
|
||||
pText);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawGrid(Canvas canvas, RectF rGrid)
|
||||
{
|
||||
int nRows = 5;
|
||||
float rowHeight = rGrid.height() / nRows;
|
||||
|
||||
pText.setTextAlign(Paint.Align.LEFT);
|
||||
pText.setColor(textColor);
|
||||
pGrid.setColor(gridColor);
|
||||
|
||||
for (int i = 0; i < nRows; i++)
|
||||
{
|
||||
canvas.drawText(String.format("%d%%", (100 - i * 100 / nRows)),
|
||||
rGrid.left + 0.5f * em, rGrid.top + 1f * em, pText);
|
||||
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top,
|
||||
pGrid);
|
||||
rGrid.offset(0, rowHeight);
|
||||
}
|
||||
|
||||
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid);
|
||||
}
|
||||
|
||||
private void drawLine(Canvas canvas, RectF rectFrom, RectF rectTo)
|
||||
{
|
||||
pGraph.setColor(primaryColor);
|
||||
canvas.drawLine(rectFrom.centerX(), rectFrom.centerY(),
|
||||
rectTo.centerX(), rectTo.centerY(), pGraph);
|
||||
}
|
||||
|
||||
private void drawMarker(Canvas canvas, RectF rect)
|
||||
{
|
||||
rect.inset(baseSize * 0.225f, baseSize * 0.225f);
|
||||
setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
|
||||
canvas.drawOval(rect, pGraph);
|
||||
|
||||
rect.inset(baseSize * 0.1f, baseSize * 0.1f);
|
||||
setModeOrColor(pGraph, XFERMODE_SRC, primaryColor);
|
||||
canvas.drawOval(rect, pGraph);
|
||||
|
||||
// rect.inset(baseSize * 0.1f, baseSize * 0.1f);
|
||||
// setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
|
||||
// canvas.drawOval(rect, pGraph);
|
||||
|
||||
if (isTransparencyEnabled) pGraph.setXfermode(XFERMODE_SRC);
|
||||
}
|
||||
|
||||
private float getMaxDayWidth()
|
||||
{
|
||||
float maxDayWidth = 0;
|
||||
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
|
||||
|
||||
for (int i = 0; i < 28; i++)
|
||||
{
|
||||
day.set(Calendar.DAY_OF_MONTH, i);
|
||||
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
|
||||
maxDayWidth = Math.max(maxDayWidth, monthWidth);
|
||||
}
|
||||
|
||||
return maxDayWidth;
|
||||
}
|
||||
|
||||
private float getMaxMonthWidth()
|
||||
{
|
||||
float maxMonthWidth = 0;
|
||||
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
day.set(Calendar.MONTH, i);
|
||||
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
|
||||
maxMonthWidth = Math.max(maxMonthWidth, monthWidth);
|
||||
}
|
||||
|
||||
return maxMonthWidth;
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
initPaints();
|
||||
initColors();
|
||||
initDateFormats();
|
||||
initRects();
|
||||
}
|
||||
|
||||
private void initCache(int width, int height)
|
||||
{
|
||||
if (drawingCache != null) drawingCache.recycle();
|
||||
drawingCache =
|
||||
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
||||
cacheCanvas = new Canvas(drawingCache);
|
||||
}
|
||||
|
||||
private void initColors()
|
||||
{
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
|
||||
primaryColor = Color.BLACK;
|
||||
textColor = res.getColor(R.attr.mediumContrastTextColor);
|
||||
gridColor = res.getColor(R.attr.lowContrastTextColor);
|
||||
backgroundColor = res.getColor(R.attr.cardBgColor);
|
||||
}
|
||||
|
||||
private void initDateFormats()
|
||||
{
|
||||
if (isInEditMode())
|
||||
{
|
||||
dfMonth = new SimpleDateFormat("MMM", Locale.getDefault());
|
||||
dfYear = new SimpleDateFormat("yyyy", Locale.getDefault());
|
||||
dfDay = new SimpleDateFormat("d", Locale.getDefault());
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
dfMonth = DateExtensionsKt.toSimpleDataFormat("MMM");
|
||||
dfYear = DateExtensionsKt.toSimpleDataFormat("yyyy");
|
||||
dfDay = DateExtensionsKt.toSimpleDataFormat("d");
|
||||
}
|
||||
}
|
||||
|
||||
private void initPaints()
|
||||
{
|
||||
pText = new Paint();
|
||||
pText.setAntiAlias(true);
|
||||
|
||||
pGraph = new Paint();
|
||||
pGraph.setTextAlign(Paint.Align.CENTER);
|
||||
pGraph.setAntiAlias(true);
|
||||
|
||||
pGrid = new Paint();
|
||||
pGrid.setAntiAlias(true);
|
||||
}
|
||||
|
||||
private void initRects()
|
||||
{
|
||||
rect = new RectF();
|
||||
prevRect = new RectF();
|
||||
}
|
||||
|
||||
private void setModeOrColor(Paint p, PorterDuffXfermode mode, int color)
|
||||
{
|
||||
if (isTransparencyEnabled) p.setXfermode(mode);
|
||||
else p.setColor(color);
|
||||
}
|
||||
}
|
@ -0,0 +1,375 @@
|
||||
/*
|
||||
* 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.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.models.Score
|
||||
import org.isoron.uhabits.core.models.Timestamp
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendarWithOffset
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
import org.isoron.uhabits.utils.toSimpleDataFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import java.util.Random
|
||||
|
||||
class ScoreChart : ScrollableChart {
|
||||
private var pGrid: Paint? = null
|
||||
private var em = 0f
|
||||
private var dfMonth: SimpleDateFormat? = null
|
||||
private var dfDay: SimpleDateFormat? = null
|
||||
private var dfYear: SimpleDateFormat? = null
|
||||
private var pText: Paint? = null
|
||||
private var pGraph: Paint? = null
|
||||
private var rect: RectF? = null
|
||||
private var prevRect: RectF? = null
|
||||
private var baseSize = 0
|
||||
private var internalPaddingTop = 0
|
||||
private var columnWidth = 0f
|
||||
private var columnHeight = 0
|
||||
private var nColumns = 0
|
||||
private var textColor = 0
|
||||
private var gridColor = 0
|
||||
private var scores: MutableList<Score>? = null
|
||||
private var primaryColor = 0
|
||||
|
||||
@Deprecated("")
|
||||
private var bucketSize = 7
|
||||
private var internalBackgroundColor = 0
|
||||
private var internalDrawingCache: Bitmap? = null
|
||||
private var cacheCanvas: Canvas? = null
|
||||
private var isTransparencyEnabled = false
|
||||
private var skipYear = 0
|
||||
private var previousYearText: String? = null
|
||||
private var previousMonthText: String? = null
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
fun populateWithRandomData() {
|
||||
val random = Random()
|
||||
val newScores = LinkedList<Score>()
|
||||
var previous = 0.5
|
||||
val timestamp: Timestamp = getToday()
|
||||
for (i in 1..99) {
|
||||
val step = 0.1
|
||||
var current = previous + random.nextDouble() * step * 2 - step
|
||||
current = Math.max(0.0, Math.min(1.0, current))
|
||||
newScores.add(Score(timestamp.minus(i), current))
|
||||
previous = current
|
||||
}
|
||||
scores = newScores
|
||||
}
|
||||
|
||||
fun setBucketSize(bucketSize: Int) {
|
||||
this.bucketSize = bucketSize
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setIsTransparencyEnabled(enabled: Boolean) {
|
||||
isTransparencyEnabled = enabled
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setColor(primaryColor: Int) {
|
||||
this.primaryColor = primaryColor
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setScores(scores: MutableList<Score>) {
|
||||
this.scores = scores
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val activeCanvas: Canvas?
|
||||
if (isTransparencyEnabled) {
|
||||
if (internalDrawingCache == null) initCache(width, height)
|
||||
activeCanvas = cacheCanvas
|
||||
internalDrawingCache!!.eraseColor(Color.TRANSPARENT)
|
||||
} else {
|
||||
activeCanvas = canvas
|
||||
}
|
||||
if (scores == null) return
|
||||
rect!![0f, 0f, nColumns * columnWidth] = columnHeight.toFloat()
|
||||
rect!!.offset(0f, internalPaddingTop.toFloat())
|
||||
drawGrid(activeCanvas, rect)
|
||||
pText!!.color = textColor
|
||||
pGraph!!.color = primaryColor
|
||||
prevRect!!.setEmpty()
|
||||
previousMonthText = ""
|
||||
previousYearText = ""
|
||||
skipYear = 0
|
||||
for (k in 0 until nColumns) {
|
||||
val offset = nColumns - k - 1 + dataOffset
|
||||
if (offset >= scores!!.size) continue
|
||||
val score = scores!![offset].value
|
||||
val timestamp = scores!![offset].timestamp
|
||||
val height = (columnHeight * score).toInt()
|
||||
rect!![0f, 0f, baseSize.toFloat()] = baseSize.toFloat()
|
||||
rect!!.offset(
|
||||
k * columnWidth + (columnWidth - baseSize) / 2,
|
||||
(
|
||||
internalPaddingTop + columnHeight - height - baseSize / 2
|
||||
).toFloat()
|
||||
)
|
||||
if (!prevRect!!.isEmpty) {
|
||||
drawLine(activeCanvas, prevRect, rect)
|
||||
drawMarker(activeCanvas, prevRect)
|
||||
}
|
||||
if (k == nColumns - 1) drawMarker(activeCanvas, rect)
|
||||
prevRect!!.set(rect)
|
||||
rect!![0f, 0f, columnWidth] = columnHeight.toFloat()
|
||||
rect!!.offset(k * columnWidth, internalPaddingTop.toFloat())
|
||||
drawFooter(activeCanvas, rect, timestamp)
|
||||
}
|
||||
if (activeCanvas !== canvas) canvas.drawBitmap(internalDrawingCache!!, 0f, 0f, null)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(
|
||||
width: Int,
|
||||
height: Int,
|
||||
oldWidth: Int,
|
||||
oldHeight: Int
|
||||
) {
|
||||
var height = height
|
||||
if (height < 9) height = 200
|
||||
val maxTextSize = getDimension(context, R.dimen.tinyTextSize)
|
||||
val textSize = height * 0.06f
|
||||
pText!!.textSize = Math.min(textSize, maxTextSize)
|
||||
em = pText!!.fontSpacing
|
||||
val footerHeight = (3 * em).toInt()
|
||||
internalPaddingTop = em.toInt()
|
||||
baseSize = (height - footerHeight - internalPaddingTop) / 8
|
||||
columnWidth = baseSize.toFloat()
|
||||
columnWidth = Math.max(columnWidth, maxDayWidth * 1.5f)
|
||||
columnWidth = Math.max(columnWidth, maxMonthWidth * 1.2f)
|
||||
nColumns = (width / columnWidth).toInt()
|
||||
columnWidth = width.toFloat() / nColumns
|
||||
setScrollerBucketSize(columnWidth.toInt())
|
||||
columnHeight = 8 * baseSize
|
||||
val minStrokeWidth = dpToPixels(context, 1f)
|
||||
pGraph!!.textSize = baseSize * 0.5f
|
||||
pGraph!!.strokeWidth = baseSize * 0.1f
|
||||
pGrid!!.strokeWidth = Math.min(minStrokeWidth, baseSize * 0.05f)
|
||||
if (isTransparencyEnabled) initCache(width, height)
|
||||
}
|
||||
|
||||
private fun drawFooter(canvas: Canvas?, rect: RectF?, currentDate: Timestamp) {
|
||||
val yearText = dfYear!!.format(currentDate.toJavaDate())
|
||||
val monthText = dfMonth!!.format(currentDate.toJavaDate())
|
||||
val dayText = dfDay!!.format(currentDate.toJavaDate())
|
||||
val calendar = currentDate.toCalendar()
|
||||
val text: String
|
||||
val year = calendar[Calendar.YEAR]
|
||||
var shouldPrintYear = true
|
||||
if (yearText == previousYearText) shouldPrintYear = false
|
||||
if (bucketSize >= 365 && year % 2 != 0) shouldPrintYear = false
|
||||
if (skipYear > 0) {
|
||||
skipYear--
|
||||
shouldPrintYear = false
|
||||
}
|
||||
if (shouldPrintYear) {
|
||||
previousYearText = yearText
|
||||
previousMonthText = ""
|
||||
pText!!.textAlign = Paint.Align.CENTER
|
||||
canvas!!.drawText(
|
||||
yearText,
|
||||
rect!!.centerX(),
|
||||
rect.bottom + em * 2.2f,
|
||||
pText!!
|
||||
)
|
||||
skipYear = 1
|
||||
}
|
||||
if (bucketSize < 365) {
|
||||
if (monthText != previousMonthText) {
|
||||
previousMonthText = monthText
|
||||
text = monthText
|
||||
} else {
|
||||
text = dayText
|
||||
}
|
||||
pText!!.textAlign = Paint.Align.CENTER
|
||||
canvas!!.drawText(
|
||||
text,
|
||||
rect!!.centerX(),
|
||||
rect.bottom + em * 1.2f,
|
||||
pText!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawGrid(canvas: Canvas?, rGrid: RectF?) {
|
||||
val nRows = 5
|
||||
val rowHeight = rGrid!!.height() / nRows
|
||||
pText!!.textAlign = Paint.Align.LEFT
|
||||
pText!!.color = textColor
|
||||
pGrid!!.color = gridColor
|
||||
for (i in 0 until nRows) {
|
||||
canvas!!.drawText(
|
||||
String.format("%d%%", 100 - i * 100 / nRows),
|
||||
rGrid.left + 0.5f * em,
|
||||
rGrid.top + 1f * em,
|
||||
pText!!
|
||||
)
|
||||
canvas.drawLine(
|
||||
rGrid.left,
|
||||
rGrid.top,
|
||||
rGrid.right,
|
||||
rGrid.top,
|
||||
pGrid!!
|
||||
)
|
||||
rGrid.offset(0f, rowHeight)
|
||||
}
|
||||
canvas!!.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid!!)
|
||||
}
|
||||
|
||||
private fun drawLine(canvas: Canvas?, rectFrom: RectF?, rectTo: RectF?) {
|
||||
pGraph!!.color = primaryColor
|
||||
canvas!!.drawLine(
|
||||
rectFrom!!.centerX(),
|
||||
rectFrom.centerY(),
|
||||
rectTo!!.centerX(),
|
||||
rectTo.centerY(),
|
||||
pGraph!!
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawMarker(canvas: Canvas?, rect: RectF?) {
|
||||
rect!!.inset(baseSize * 0.225f, baseSize * 0.225f)
|
||||
setModeOrColor(pGraph, XFERMODE_CLEAR, internalBackgroundColor)
|
||||
canvas!!.drawOval(rect, pGraph!!)
|
||||
rect.inset(baseSize * 0.1f, baseSize * 0.1f)
|
||||
setModeOrColor(pGraph, XFERMODE_SRC, primaryColor)
|
||||
canvas.drawOval(rect, pGraph!!)
|
||||
|
||||
// rect.inset(baseSize * 0.1f, baseSize * 0.1f);
|
||||
// setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
|
||||
// canvas.drawOval(rect, pGraph);
|
||||
if (isTransparencyEnabled) pGraph!!.xfermode = XFERMODE_SRC
|
||||
}
|
||||
|
||||
private val maxDayWidth: Float
|
||||
private get() {
|
||||
var maxDayWidth = 0f
|
||||
val day: GregorianCalendar =
|
||||
getStartOfTodayCalendarWithOffset()
|
||||
for (i in 0..27) {
|
||||
day[Calendar.DAY_OF_MONTH] = i
|
||||
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
|
||||
maxDayWidth = Math.max(maxDayWidth, monthWidth)
|
||||
}
|
||||
return maxDayWidth
|
||||
}
|
||||
private val maxMonthWidth: Float
|
||||
private get() {
|
||||
var maxMonthWidth = 0f
|
||||
val day: GregorianCalendar =
|
||||
getStartOfTodayCalendarWithOffset()
|
||||
for (i in 0..11) {
|
||||
day[Calendar.MONTH] = i
|
||||
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
|
||||
maxMonthWidth = Math.max(maxMonthWidth, monthWidth)
|
||||
}
|
||||
return maxMonthWidth
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
initPaints()
|
||||
initColors()
|
||||
initDateFormats()
|
||||
initRects()
|
||||
}
|
||||
|
||||
private fun initCache(width: Int, height: Int) {
|
||||
if (internalDrawingCache != null) internalDrawingCache!!.recycle()
|
||||
val newDrawingCache = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
internalDrawingCache = newDrawingCache
|
||||
cacheCanvas = Canvas(newDrawingCache)
|
||||
}
|
||||
|
||||
private fun initColors() {
|
||||
val res = StyledResources(context)
|
||||
primaryColor = Color.BLACK
|
||||
textColor = res.getColor(R.attr.mediumContrastTextColor)
|
||||
gridColor = res.getColor(R.attr.lowContrastTextColor)
|
||||
internalBackgroundColor = res.getColor(R.attr.cardBgColor)
|
||||
}
|
||||
|
||||
private fun initDateFormats() {
|
||||
if (isInEditMode) {
|
||||
dfMonth = SimpleDateFormat("MMM", Locale.getDefault())
|
||||
dfYear = SimpleDateFormat("yyyy", Locale.getDefault())
|
||||
dfDay = SimpleDateFormat("d", Locale.getDefault())
|
||||
} else {
|
||||
dfMonth = "MMM".toSimpleDataFormat()
|
||||
dfYear = "yyyy".toSimpleDataFormat()
|
||||
dfDay = "d".toSimpleDataFormat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPaints() {
|
||||
pText = Paint()
|
||||
pText!!.isAntiAlias = true
|
||||
pGraph = Paint()
|
||||
pGraph!!.textAlign = Paint.Align.CENTER
|
||||
pGraph!!.isAntiAlias = true
|
||||
pGrid = Paint()
|
||||
pGrid!!.isAntiAlias = true
|
||||
}
|
||||
|
||||
private fun initRects() {
|
||||
rect = RectF()
|
||||
prevRect = RectF()
|
||||
}
|
||||
|
||||
private fun setModeOrColor(p: Paint?, mode: PorterDuffXfermode, color: Int) {
|
||||
if (isTransparencyEnabled) p!!.xfermode = mode else p!!.color = color
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
|
||||
private val XFERMODE_SRC = PorterDuffXfermode(PorterDuff.Mode.SRC)
|
||||
}
|
||||
}
|
@ -1,245 +0,0 @@
|
||||
/*
|
||||
* 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.animation.*;
|
||||
import android.content.*;
|
||||
import android.os.*;
|
||||
import android.util.*;
|
||||
import android.view.*;
|
||||
import android.widget.*;
|
||||
|
||||
public abstract class ScrollableChart extends View
|
||||
implements GestureDetector.OnGestureListener,
|
||||
ValueAnimator.AnimatorUpdateListener
|
||||
{
|
||||
|
||||
private int dataOffset;
|
||||
|
||||
private int scrollerBucketSize = 1;
|
||||
|
||||
private int direction = 1;
|
||||
|
||||
private GestureDetector detector;
|
||||
|
||||
private Scroller scroller;
|
||||
|
||||
private ValueAnimator scrollAnimator;
|
||||
|
||||
private ScrollController scrollController;
|
||||
|
||||
private int maxDataOffset = 10000;
|
||||
|
||||
public ScrollableChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init(context);
|
||||
}
|
||||
|
||||
public ScrollableChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
init(context);
|
||||
}
|
||||
|
||||
public int getDataOffset()
|
||||
{
|
||||
return dataOffset;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationUpdate(ValueAnimator animation)
|
||||
{
|
||||
if (!scroller.isFinished())
|
||||
{
|
||||
scroller.computeScrollOffset();
|
||||
updateDataOffset();
|
||||
}
|
||||
else
|
||||
{
|
||||
scrollAnimator.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onFling(MotionEvent e1,
|
||||
MotionEvent e2,
|
||||
float velocityX,
|
||||
float velocityY)
|
||||
{
|
||||
scroller.fling(scroller.getCurrX(), scroller.getCurrY(),
|
||||
direction * ((int) velocityX) / 2, 0, 0, getMaxX(), 0, 0);
|
||||
invalidate();
|
||||
|
||||
scrollAnimator.setDuration(scroller.getDuration());
|
||||
scrollAnimator.start();
|
||||
return false;
|
||||
}
|
||||
|
||||
private int getMaxX()
|
||||
{
|
||||
return maxDataOffset * scrollerBucketSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRestoreInstanceState(Parcelable state)
|
||||
{
|
||||
if(!(state instanceof BundleSavedState))
|
||||
{
|
||||
super.onRestoreInstanceState(state);
|
||||
return;
|
||||
}
|
||||
|
||||
BundleSavedState bss = (BundleSavedState) state;
|
||||
int x = bss.bundle.getInt("x");
|
||||
int y = bss.bundle.getInt("y");
|
||||
direction = bss.bundle.getInt("direction");
|
||||
dataOffset = bss.bundle.getInt("dataOffset");
|
||||
maxDataOffset = bss.bundle.getInt("maxDataOffset");
|
||||
scroller.startScroll(0, 0, x, y, 0);
|
||||
scroller.computeScrollOffset();
|
||||
super.onRestoreInstanceState(bss.getSuperState());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Parcelable onSaveInstanceState()
|
||||
{
|
||||
Parcelable superState = super.onSaveInstanceState();
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putInt("x", scroller.getCurrX());
|
||||
bundle.putInt("y", scroller.getCurrY());
|
||||
bundle.putInt("dataOffset", dataOffset);
|
||||
bundle.putInt("direction", direction);
|
||||
bundle.putInt("maxDataOffset", maxDataOffset);
|
||||
return new BundleSavedState(superState, bundle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy)
|
||||
{
|
||||
if (scrollerBucketSize == 0) return false;
|
||||
|
||||
if (Math.abs(dx) > Math.abs(dy))
|
||||
{
|
||||
ViewParent parent = getParent();
|
||||
if (parent != null) parent.requestDisallowInterceptTouchEvent(true);
|
||||
}
|
||||
|
||||
|
||||
dx = - direction * dx;
|
||||
dx = Math.min(dx, getMaxX() - scroller.getCurrX());
|
||||
scroller.startScroll(scroller.getCurrX(), scroller.getCurrY(), (int) dx,
|
||||
(int) dy, 0);
|
||||
|
||||
scroller.computeScrollOffset();
|
||||
updateDataOffset();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowPress(MotionEvent e)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event)
|
||||
{
|
||||
return detector.onTouchEvent(event);
|
||||
}
|
||||
|
||||
public void setScrollDirection(int direction)
|
||||
{
|
||||
if (direction != 1 && direction != -1)
|
||||
throw new IllegalArgumentException();
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public void setMaxDataOffset(int maxDataOffset)
|
||||
{
|
||||
this.maxDataOffset = maxDataOffset;
|
||||
this.dataOffset = Math.min(dataOffset, maxDataOffset);
|
||||
scrollController.onDataOffsetChanged(this.dataOffset);
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setScrollController(ScrollController scrollController)
|
||||
{
|
||||
this.scrollController = scrollController;
|
||||
}
|
||||
|
||||
public void setScrollerBucketSize(int scrollerBucketSize)
|
||||
{
|
||||
this.scrollerBucketSize = scrollerBucketSize;
|
||||
}
|
||||
|
||||
private void init(Context context)
|
||||
{
|
||||
detector = new GestureDetector(context, this);
|
||||
scroller = new Scroller(context, null, true);
|
||||
scrollAnimator = ValueAnimator.ofFloat(0, 1);
|
||||
scrollAnimator.addUpdateListener(this);
|
||||
scrollController = new ScrollController() {};
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
scroller.setFinalX(0);
|
||||
scroller.computeScrollOffset();
|
||||
updateDataOffset();
|
||||
}
|
||||
|
||||
private void updateDataOffset()
|
||||
{
|
||||
int newDataOffset = scroller.getCurrX() / scrollerBucketSize;
|
||||
newDataOffset = Math.max(0, newDataOffset);
|
||||
newDataOffset = Math.min(maxDataOffset, newDataOffset);
|
||||
|
||||
if (newDataOffset != dataOffset)
|
||||
{
|
||||
dataOffset = newDataOffset;
|
||||
scrollController.onDataOffsetChanged(dataOffset);
|
||||
postInvalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public interface ScrollController
|
||||
{
|
||||
default void onDataOffsetChanged(int newDataOffset) {}
|
||||
}
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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.animation.ValueAnimator
|
||||
import android.animation.ValueAnimator.AnimatorUpdateListener
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.Scroller
|
||||
|
||||
abstract class ScrollableChart : View, GestureDetector.OnGestureListener, AnimatorUpdateListener {
|
||||
var dataOffset = 0
|
||||
private set
|
||||
private var scrollerBucketSize = 1
|
||||
private var direction = 1
|
||||
private var detector: GestureDetector? = null
|
||||
private var scroller: Scroller? = null
|
||||
private var scrollAnimator: ValueAnimator? = null
|
||||
private var scrollController: ScrollController? = null
|
||||
private var maxDataOffset = 10000
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init(context)
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init(context)
|
||||
}
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
if (!scroller!!.isFinished) {
|
||||
scroller!!.computeScrollOffset()
|
||||
updateDataOffset()
|
||||
} else {
|
||||
scrollAnimator!!.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onFling(
|
||||
e1: MotionEvent,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
scroller!!.fling(
|
||||
scroller!!.currX,
|
||||
scroller!!.currY,
|
||||
direction * velocityX.toInt() / 2,
|
||||
0,
|
||||
0,
|
||||
maxX,
|
||||
0,
|
||||
0
|
||||
)
|
||||
invalidate()
|
||||
scrollAnimator!!.duration = scroller!!.duration.toLong()
|
||||
scrollAnimator!!.start()
|
||||
return false
|
||||
}
|
||||
|
||||
private val maxX: Int
|
||||
private get() = maxDataOffset * scrollerBucketSize
|
||||
|
||||
public override fun onRestoreInstanceState(state: Parcelable) {
|
||||
if (state !is BundleSavedState) {
|
||||
super.onRestoreInstanceState(state)
|
||||
return
|
||||
}
|
||||
val bss = state
|
||||
val x = bss.bundle!!.getInt("x")
|
||||
val y = bss.bundle.getInt("y")
|
||||
direction = bss.bundle.getInt("direction")
|
||||
dataOffset = bss.bundle.getInt("dataOffset")
|
||||
maxDataOffset = bss.bundle.getInt("maxDataOffset")
|
||||
scroller!!.startScroll(0, 0, x, y, 0)
|
||||
scroller!!.computeScrollOffset()
|
||||
super.onRestoreInstanceState(bss.superState)
|
||||
}
|
||||
|
||||
public override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState()
|
||||
val bundle = Bundle()
|
||||
bundle.putInt("x", scroller!!.currX)
|
||||
bundle.putInt("y", scroller!!.currY)
|
||||
bundle.putInt("dataOffset", dataOffset)
|
||||
bundle.putInt("direction", direction)
|
||||
bundle.putInt("maxDataOffset", maxDataOffset)
|
||||
return BundleSavedState(superState, bundle)
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, dx: Float, dy: Float): Boolean {
|
||||
var dx = dx
|
||||
if (scrollerBucketSize == 0) return false
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
val parent = parent
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
dx = -direction * dx
|
||||
dx = Math.min(dx, (maxX - scroller!!.currX).toFloat())
|
||||
scroller!!.startScroll(
|
||||
scroller!!.currX,
|
||||
scroller!!.currY,
|
||||
dx.toInt(),
|
||||
dy.toInt(),
|
||||
0
|
||||
)
|
||||
scroller!!.computeScrollOffset()
|
||||
updateDataOffset()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onShowPress(e: MotionEvent) {}
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return detector!!.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun setScrollDirection(direction: Int) {
|
||||
require(!(direction != 1 && direction != -1))
|
||||
this.direction = direction
|
||||
}
|
||||
|
||||
override fun onLongPress(e: MotionEvent) {}
|
||||
fun setMaxDataOffset(maxDataOffset: Int) {
|
||||
this.maxDataOffset = maxDataOffset
|
||||
dataOffset = Math.min(dataOffset, maxDataOffset)
|
||||
scrollController!!.onDataOffsetChanged(dataOffset)
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setScrollController(scrollController: ScrollController?) {
|
||||
this.scrollController = scrollController
|
||||
}
|
||||
|
||||
fun setScrollerBucketSize(scrollerBucketSize: Int) {
|
||||
this.scrollerBucketSize = scrollerBucketSize
|
||||
}
|
||||
|
||||
private fun init(context: Context?) {
|
||||
detector = GestureDetector(context, this)
|
||||
scroller = Scroller(context, null, true)
|
||||
val newScrollAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||
newScrollAnimator.addUpdateListener(this)
|
||||
scrollAnimator = newScrollAnimator
|
||||
scrollController = object : ScrollController {}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
scroller!!.finalX = 0
|
||||
scroller!!.computeScrollOffset()
|
||||
updateDataOffset()
|
||||
}
|
||||
|
||||
private fun updateDataOffset() {
|
||||
var newDataOffset = scroller!!.currX / scrollerBucketSize
|
||||
newDataOffset = Math.max(0, newDataOffset)
|
||||
newDataOffset = Math.min(maxDataOffset, newDataOffset)
|
||||
if (newDataOffset != dataOffset) {
|
||||
dataOffset = newDataOffset
|
||||
scrollController!!.onDataOffsetChanged(dataOffset)
|
||||
postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
interface ScrollController {
|
||||
fun onDataOffsetChanged(newDataOffset: Int) {}
|
||||
}
|
||||
}
|
@ -1,321 +0,0 @@
|
||||
/*
|
||||
* 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.RectF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup.LayoutParams;
|
||||
|
||||
import org.isoron.uhabits.R;
|
||||
import org.isoron.uhabits.core.models.Streak;
|
||||
import org.isoron.uhabits.core.models.Timestamp;
|
||||
import org.isoron.uhabits.core.utils.DateUtils;
|
||||
import org.isoron.uhabits.utils.StyledResources;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import static android.view.View.MeasureSpec.*;
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
|
||||
public class StreakChart extends View
|
||||
{
|
||||
private Paint paint;
|
||||
|
||||
private long minLength;
|
||||
|
||||
private long maxLength;
|
||||
|
||||
private int[] colors;
|
||||
|
||||
private int[] textColors;
|
||||
|
||||
private RectF rect;
|
||||
|
||||
private int baseSize;
|
||||
|
||||
private int primaryColor;
|
||||
|
||||
private List<Streak> streaks;
|
||||
|
||||
private boolean isBackgroundTransparent;
|
||||
|
||||
private DateFormat dateFormat;
|
||||
|
||||
private int width;
|
||||
|
||||
private float em;
|
||||
|
||||
private float maxLabelWidth;
|
||||
|
||||
private float textMargin;
|
||||
|
||||
private boolean shouldShowLabels;
|
||||
|
||||
private int textColor;
|
||||
|
||||
public StreakChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public StreakChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of streaks this view is able to show, given
|
||||
* its current size.
|
||||
*
|
||||
* @return max number of visible streaks
|
||||
*/
|
||||
public int getMaxStreakCount()
|
||||
{
|
||||
return (int) Math.floor(getMeasuredHeight() / baseSize);
|
||||
}
|
||||
|
||||
public void populateWithRandomData()
|
||||
{
|
||||
Timestamp start = DateUtils.getToday();
|
||||
List<Streak> streaks = new LinkedList<>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
int length = new Random().nextInt(100);
|
||||
Timestamp end = start.plus(length);
|
||||
streaks.add(new Streak(start, end));
|
||||
start = end.plus(1);
|
||||
}
|
||||
|
||||
setStreaks(streaks);
|
||||
}
|
||||
|
||||
public void setColor(int color)
|
||||
{
|
||||
this.primaryColor = color;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setIsBackgroundTransparent(boolean isBackgroundTransparent)
|
||||
{
|
||||
this.isBackgroundTransparent = isBackgroundTransparent;
|
||||
initColors();
|
||||
}
|
||||
|
||||
public void setStreaks(List<Streak> streaks)
|
||||
{
|
||||
this.streaks = streaks;
|
||||
initColors();
|
||||
updateMaxMinLengths();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
if (streaks.size() == 0) return;
|
||||
|
||||
rect.set(0, 0, width, baseSize);
|
||||
|
||||
for (Streak s : streaks)
|
||||
{
|
||||
drawRow(canvas, s, rect);
|
||||
rect.offset(0, baseSize);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthSpec, int heightSpec)
|
||||
{
|
||||
LayoutParams params = getLayoutParams();
|
||||
|
||||
if (params != null && params.height == LayoutParams.WRAP_CONTENT)
|
||||
{
|
||||
int width = getSize(widthSpec);
|
||||
int height = streaks.size() * baseSize;
|
||||
|
||||
heightSpec = makeMeasureSpec(height, EXACTLY);
|
||||
widthSpec = makeMeasureSpec(width, EXACTLY);
|
||||
}
|
||||
|
||||
setMeasuredDimension(widthSpec, heightSpec);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int width,
|
||||
int height,
|
||||
int oldWidth,
|
||||
int oldHeight)
|
||||
{
|
||||
this.width = width;
|
||||
|
||||
Context context = getContext();
|
||||
float minTextSize = getDimension(context, R.dimen.tinyTextSize);
|
||||
float maxTextSize = getDimension(context, R.dimen.regularTextSize);
|
||||
float textSize = baseSize * 0.5f;
|
||||
|
||||
paint.setTextSize(
|
||||
Math.max(Math.min(textSize, maxTextSize), minTextSize));
|
||||
em = paint.getFontSpacing();
|
||||
textMargin = 0.5f * em;
|
||||
|
||||
updateMaxMinLengths();
|
||||
}
|
||||
|
||||
private void drawRow(Canvas canvas, Streak streak, RectF rect)
|
||||
{
|
||||
if (maxLength == 0) return;
|
||||
|
||||
float percentage = (float) streak.getLength() / maxLength;
|
||||
float availableWidth = width - 2 * maxLabelWidth;
|
||||
if (shouldShowLabels) availableWidth -= 2 * textMargin;
|
||||
|
||||
float barWidth = percentage * availableWidth;
|
||||
float minBarWidth =
|
||||
paint.measureText(Long.toString(streak.getLength())) + em;
|
||||
barWidth = Math.max(barWidth, minBarWidth);
|
||||
|
||||
float gap = (width - barWidth) / 2;
|
||||
float paddingTopBottom = baseSize * 0.05f;
|
||||
|
||||
paint.setColor(percentageToColor(percentage));
|
||||
|
||||
float round = dpToPixels(getContext(), 2);
|
||||
canvas.drawRoundRect(rect.left + gap,
|
||||
rect.top + paddingTopBottom,
|
||||
rect.right - gap,
|
||||
rect.bottom - paddingTopBottom,
|
||||
round,
|
||||
round,
|
||||
paint);
|
||||
|
||||
float yOffset = rect.centerY() + 0.3f * em;
|
||||
|
||||
paint.setColor(percentageToTextColor(percentage));
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
canvas.drawText(Long.toString(streak.getLength()), rect.centerX(),
|
||||
yOffset, paint);
|
||||
|
||||
if (shouldShowLabels)
|
||||
{
|
||||
String startLabel = dateFormat.format(streak.getStart().toJavaDate());
|
||||
String endLabel = dateFormat.format(streak.getEnd().toJavaDate());
|
||||
|
||||
paint.setColor(textColors[1]);
|
||||
paint.setTextAlign(Paint.Align.RIGHT);
|
||||
canvas.drawText(startLabel, gap - textMargin, yOffset, paint);
|
||||
|
||||
paint.setTextAlign(Paint.Align.LEFT);
|
||||
canvas.drawText(endLabel, width - gap + textMargin, yOffset, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
initPaints();
|
||||
initColors();
|
||||
|
||||
streaks = Collections.emptyList();
|
||||
|
||||
dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM);
|
||||
if (!isInEditMode()) dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
|
||||
rect = new RectF();
|
||||
baseSize = getResources().getDimensionPixelSize(R.dimen.baseSize);
|
||||
}
|
||||
|
||||
private void initColors()
|
||||
{
|
||||
int red = Color.red(primaryColor);
|
||||
int green = Color.green(primaryColor);
|
||||
int blue = Color.blue(primaryColor);
|
||||
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
|
||||
colors = new int[4];
|
||||
colors[3] = primaryColor;
|
||||
colors[2] = Color.argb(192, red, green, blue);
|
||||
colors[1] = Color.argb(96, red, green, blue);
|
||||
colors[0] = res.getColor(R.attr.lowContrastTextColor);
|
||||
|
||||
textColors = new int[3];
|
||||
textColors[2] = res.getColor(R.attr.highContrastReverseTextColor);
|
||||
textColors[1] = res.getColor(R.attr.mediumContrastTextColor);
|
||||
textColors[0] = res.getColor(R.attr.lowContrastReverseTextColor);
|
||||
}
|
||||
|
||||
private void initPaints()
|
||||
{
|
||||
paint = new Paint();
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setAntiAlias(true);
|
||||
}
|
||||
|
||||
private int percentageToColor(float percentage)
|
||||
{
|
||||
if (percentage >= 1.0f) return colors[3];
|
||||
if (percentage >= 0.8f) return colors[2];
|
||||
if (percentage >= 0.5f) return colors[1];
|
||||
return colors[0];
|
||||
}
|
||||
|
||||
private int percentageToTextColor(float percentage)
|
||||
{
|
||||
if (percentage >= 0.5f) return textColors[2];
|
||||
return textColors[1];
|
||||
}
|
||||
|
||||
private void updateMaxMinLengths()
|
||||
{
|
||||
maxLength = 0;
|
||||
minLength = Long.MAX_VALUE;
|
||||
shouldShowLabels = true;
|
||||
|
||||
for (Streak s : streaks)
|
||||
{
|
||||
maxLength = Math.max(maxLength, s.getLength());
|
||||
minLength = Math.min(minLength, s.getLength());
|
||||
|
||||
float lw1 =
|
||||
paint.measureText(dateFormat.format(s.getStart().toJavaDate()));
|
||||
float lw2 =
|
||||
paint.measureText(dateFormat.format(s.getEnd().toJavaDate()));
|
||||
maxLabelWidth = Math.max(maxLabelWidth, Math.max(lw1, lw2));
|
||||
}
|
||||
|
||||
if (width - 2 * maxLabelWidth < width * 0.25f)
|
||||
{
|
||||
maxLabelWidth = 0;
|
||||
shouldShowLabels = false;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.models.Streak
|
||||
import org.isoron.uhabits.core.models.Timestamp
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
import java.text.DateFormat
|
||||
import java.util.LinkedList
|
||||
import java.util.Random
|
||||
import java.util.TimeZone
|
||||
|
||||
class StreakChart : View {
|
||||
private var paint: Paint? = null
|
||||
private var minLength: Long = 0
|
||||
private var maxLength: Long = 0
|
||||
private lateinit var colors: IntArray
|
||||
private lateinit var textColors: IntArray
|
||||
private var rect: RectF? = null
|
||||
private var baseSize = 0
|
||||
private var primaryColor = 0
|
||||
private var streaks: List<Streak>? = null
|
||||
private var isBackgroundTransparent = false
|
||||
private var dateFormat: DateFormat? = null
|
||||
private var internalWidth = 0
|
||||
private var em = 0f
|
||||
private var maxLabelWidth = 0f
|
||||
private var textMargin = 0f
|
||||
private var shouldShowLabels = false
|
||||
private val textColor = 0
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of streaks this view is able to show, given
|
||||
* its current size.
|
||||
*
|
||||
* @return max number of visible streaks
|
||||
*/
|
||||
val maxStreakCount: Int
|
||||
get() = Math.floor((measuredHeight / baseSize).toDouble()).toInt()
|
||||
|
||||
fun populateWithRandomData() {
|
||||
var start: Timestamp = getToday()
|
||||
val streaks: MutableList<Streak> = LinkedList()
|
||||
for (i in 0..9) {
|
||||
val length = Random().nextInt(100)
|
||||
val end = start.plus(length)
|
||||
streaks.add(Streak(start, end))
|
||||
start = end.plus(1)
|
||||
}
|
||||
setStreaks(streaks)
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
primaryColor = color
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
fun setIsBackgroundTransparent(isBackgroundTransparent: Boolean) {
|
||||
this.isBackgroundTransparent = isBackgroundTransparent
|
||||
initColors()
|
||||
}
|
||||
|
||||
fun setStreaks(streaks: List<Streak>?) {
|
||||
this.streaks = streaks
|
||||
initColors()
|
||||
updateMaxMinLengths()
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (streaks!!.size == 0) return
|
||||
rect!![0f, 0f, internalWidth.toFloat()] = baseSize.toFloat()
|
||||
for (s in streaks!!) {
|
||||
drawRow(canvas, s, rect)
|
||||
rect!!.offset(0f, baseSize.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
var widthSpec = widthSpec
|
||||
var heightSpec = heightSpec
|
||||
val params = layoutParams
|
||||
if (params != null && params.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
val width = MeasureSpec.getSize(widthSpec)
|
||||
val height = streaks!!.size * baseSize
|
||||
heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
|
||||
}
|
||||
setMeasuredDimension(widthSpec, heightSpec)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(
|
||||
width: Int,
|
||||
height: Int,
|
||||
oldWidth: Int,
|
||||
oldHeight: Int
|
||||
) {
|
||||
this.internalWidth = width
|
||||
val context = context
|
||||
val minTextSize = getDimension(context, R.dimen.tinyTextSize)
|
||||
val maxTextSize = getDimension(context, R.dimen.regularTextSize)
|
||||
val textSize = baseSize * 0.5f
|
||||
paint!!.textSize = Math.max(Math.min(textSize, maxTextSize), minTextSize)
|
||||
em = paint!!.fontSpacing
|
||||
textMargin = 0.5f * em
|
||||
updateMaxMinLengths()
|
||||
}
|
||||
|
||||
private fun drawRow(canvas: Canvas, streak: Streak, rect: RectF?) {
|
||||
if (maxLength == 0L) return
|
||||
val percentage = streak.length.toFloat() / maxLength
|
||||
var availableWidth = internalWidth - 2 * maxLabelWidth
|
||||
if (shouldShowLabels) availableWidth -= 2 * textMargin
|
||||
var barWidth = percentage * availableWidth
|
||||
val minBarWidth = paint!!.measureText(java.lang.Long.toString(streak.length.toLong())) + em
|
||||
barWidth = Math.max(barWidth, minBarWidth)
|
||||
val gap = (internalWidth - barWidth) / 2
|
||||
val paddingTopBottom = baseSize * 0.05f
|
||||
paint!!.color = percentageToColor(percentage)
|
||||
val round = dpToPixels(context, 2f)
|
||||
canvas.drawRoundRect(
|
||||
rect!!.left + gap,
|
||||
rect.top + paddingTopBottom,
|
||||
rect.right - gap,
|
||||
rect.bottom - paddingTopBottom,
|
||||
round,
|
||||
round,
|
||||
paint!!
|
||||
)
|
||||
val yOffset = rect.centerY() + 0.3f * em
|
||||
paint!!.color = percentageToTextColor(percentage)
|
||||
paint!!.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText(
|
||||
java.lang.Long.toString(streak.length.toLong()),
|
||||
rect.centerX(),
|
||||
yOffset,
|
||||
paint!!
|
||||
)
|
||||
if (shouldShowLabels) {
|
||||
val startLabel = dateFormat!!.format(streak.start.toJavaDate())
|
||||
val endLabel = dateFormat!!.format(streak.end.toJavaDate())
|
||||
paint!!.color = textColors[1]
|
||||
paint!!.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawText(startLabel, gap - textMargin, yOffset, paint!!)
|
||||
paint!!.textAlign = Paint.Align.LEFT
|
||||
canvas.drawText(endLabel, internalWidth - gap + textMargin, yOffset, paint!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
initPaints()
|
||||
initColors()
|
||||
streaks = emptyList()
|
||||
val newDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM)
|
||||
if (!isInEditMode) newDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"))
|
||||
dateFormat = newDateFormat
|
||||
rect = RectF()
|
||||
baseSize = resources.getDimensionPixelSize(R.dimen.baseSize)
|
||||
}
|
||||
|
||||
private fun initColors() {
|
||||
val red = Color.red(primaryColor)
|
||||
val green = Color.green(primaryColor)
|
||||
val blue = Color.blue(primaryColor)
|
||||
val res = StyledResources(context)
|
||||
colors = IntArray(4)
|
||||
colors[3] = primaryColor
|
||||
colors[2] = Color.argb(192, red, green, blue)
|
||||
colors[1] = Color.argb(96, red, green, blue)
|
||||
colors[0] = res.getColor(R.attr.lowContrastTextColor)
|
||||
textColors = IntArray(3)
|
||||
textColors[2] = res.getColor(R.attr.highContrastReverseTextColor)
|
||||
textColors[1] = res.getColor(R.attr.mediumContrastTextColor)
|
||||
textColors[0] = res.getColor(R.attr.lowContrastReverseTextColor)
|
||||
}
|
||||
|
||||
private fun initPaints() {
|
||||
paint = Paint()
|
||||
paint!!.textAlign = Paint.Align.CENTER
|
||||
paint!!.isAntiAlias = true
|
||||
}
|
||||
|
||||
private fun percentageToColor(percentage: Float): Int {
|
||||
if (percentage >= 1.0f) return colors[3]
|
||||
if (percentage >= 0.8f) return colors[2]
|
||||
return if (percentage >= 0.5f) colors[1] else colors[0]
|
||||
}
|
||||
|
||||
private fun percentageToTextColor(percentage: Float): Int {
|
||||
return if (percentage >= 0.5f) textColors[2] else textColors[1]
|
||||
}
|
||||
|
||||
private fun updateMaxMinLengths() {
|
||||
maxLength = 0
|
||||
minLength = Long.MAX_VALUE
|
||||
shouldShowLabels = true
|
||||
for (s in streaks!!) {
|
||||
maxLength = Math.max(maxLength, s.length.toLong())
|
||||
minLength = Math.min(minLength, s.length.toLong())
|
||||
val lw1 = paint!!.measureText(dateFormat!!.format(s.start.toJavaDate()))
|
||||
val lw2 = paint!!.measureText(dateFormat!!.format(s.end.toJavaDate()))
|
||||
maxLabelWidth = Math.max(maxLabelWidth, Math.max(lw1, lw2))
|
||||
}
|
||||
if (internalWidth - 2 * maxLabelWidth < internalWidth * 0.25f) {
|
||||
maxLabelWidth = 0f
|
||||
shouldShowLabels = false
|
||||
}
|
||||
}
|
||||
}
|
@ -1,226 +0,0 @@
|
||||
/*
|
||||
* 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.*;
|
||||
import android.graphics.*;
|
||||
import android.util.*;
|
||||
import android.view.*;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.activities.habits.list.views.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static android.view.View.MeasureSpec.*;
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
|
||||
public class TargetChart extends View
|
||||
{
|
||||
private Paint paint;
|
||||
private int baseSize;
|
||||
private int primaryColor;
|
||||
private int mediumContrastTextColor;
|
||||
private int highContrastReverseTextColor;
|
||||
private int lowContrastTextColor;
|
||||
private RectF rect = new RectF();
|
||||
private RectF barRect = new RectF();
|
||||
private List<Double> values = Collections.emptyList();
|
||||
private List<String> labels = Collections.emptyList();
|
||||
private List<Double> targets = Collections.emptyList();
|
||||
private float maxLabelSize;
|
||||
private float tinyTextSize;
|
||||
|
||||
public TargetChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public TargetChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public void populateWithRandomData()
|
||||
{
|
||||
labels = new ArrayList<>();
|
||||
values = new ArrayList<>();
|
||||
targets = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
double percentage = new Random().nextDouble();
|
||||
targets.add(new Random().nextDouble() * 1000.0);
|
||||
values.add(targets.get(i) * percentage * 1.2);
|
||||
labels.add(String.format(Locale.US, "Label %d", i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
public void setColor(int color)
|
||||
{
|
||||
this.primaryColor = color;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
if (labels.size() == 0) return;
|
||||
|
||||
maxLabelSize = 0;
|
||||
for (String label : labels) {
|
||||
paint.setTextSize(tinyTextSize);
|
||||
float len = paint.measureText(label);
|
||||
maxLabelSize = Math.max(maxLabelSize, len);
|
||||
}
|
||||
|
||||
float marginTop = (getHeight() - baseSize * labels.size()) / 2.0f;
|
||||
rect.set(0, marginTop, getWidth(), marginTop + baseSize);
|
||||
for (int i = 0; i < labels.size(); i++) {
|
||||
drawRow(canvas, i, rect);
|
||||
rect.offset(0, baseSize);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthSpec, int heightSpec)
|
||||
{
|
||||
baseSize = getResources().getDimensionPixelSize(R.dimen.baseSize);
|
||||
|
||||
int width = getSize(widthSpec);
|
||||
int height = labels.size() * baseSize;
|
||||
|
||||
ViewGroup.LayoutParams params = getLayoutParams();
|
||||
if (params != null && params.height == ViewGroup.LayoutParams.MATCH_PARENT) {
|
||||
height = getSize(heightSpec);
|
||||
if (labels.size() > 0) baseSize = height / labels.size();
|
||||
}
|
||||
|
||||
heightSpec = makeMeasureSpec(height, EXACTLY);
|
||||
widthSpec = makeMeasureSpec(width, EXACTLY);
|
||||
setMeasuredDimension(widthSpec, heightSpec);
|
||||
}
|
||||
|
||||
private void drawRow(Canvas canvas, int row, RectF rect)
|
||||
{
|
||||
float padding = dpToPixels(getContext(), 4);
|
||||
float round = dpToPixels(getContext(), 2);
|
||||
float stop = maxLabelSize + padding * 2;
|
||||
|
||||
paint.setColor(mediumContrastTextColor);
|
||||
|
||||
// Draw label
|
||||
paint.setTextSize(tinyTextSize);
|
||||
paint.setTextAlign(Paint.Align.RIGHT);
|
||||
float yTextAdjust = (paint.descent() + paint.ascent()) / 2.0f;
|
||||
canvas.drawText(labels.get(row),
|
||||
rect.left + stop - padding,
|
||||
rect.centerY() - yTextAdjust,
|
||||
paint);
|
||||
|
||||
// Draw background box
|
||||
paint.setColor(lowContrastTextColor);
|
||||
barRect.set(rect.left + stop + padding,
|
||||
rect.top + baseSize * 0.05f,
|
||||
rect.right - padding,
|
||||
rect.bottom - baseSize * 0.05f);
|
||||
canvas.drawRoundRect(barRect, round, round, paint);
|
||||
|
||||
float percentage = (float) (values.get(row) / targets.get(row));
|
||||
percentage = Math.min(1.0f, percentage);
|
||||
|
||||
// Draw completed box
|
||||
float completedWidth = percentage * barRect.width();
|
||||
if (completedWidth > 0 && completedWidth < 2 * round) {
|
||||
completedWidth = 2 * round;
|
||||
}
|
||||
float remainingWidth = barRect.width() - completedWidth;
|
||||
|
||||
paint.setColor(primaryColor);
|
||||
barRect.set(barRect.left,
|
||||
barRect.top,
|
||||
barRect.left + completedWidth,
|
||||
barRect.bottom);
|
||||
canvas.drawRoundRect(barRect, round, round, paint);
|
||||
|
||||
// Draw values
|
||||
paint.setColor(Color.WHITE);
|
||||
paint.setTextSize(tinyTextSize);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
yTextAdjust = (paint.descent() + paint.ascent()) / 2.0f;
|
||||
|
||||
double remaining = targets.get(row) - values.get(row);
|
||||
String completedText = NumberButtonViewKt.toShortString(values.get(row));
|
||||
String remainingText = NumberButtonViewKt.toShortString(remaining);
|
||||
|
||||
if (completedWidth > paint.measureText(completedText) + 2 * padding) {
|
||||
paint.setColor(highContrastReverseTextColor);
|
||||
canvas.drawText(completedText,
|
||||
barRect.centerX(),
|
||||
barRect.centerY() - yTextAdjust,
|
||||
paint);
|
||||
}
|
||||
|
||||
if (remainingWidth > paint.measureText(remainingText) + 2 * padding) {
|
||||
paint.setColor(mediumContrastTextColor);
|
||||
barRect.set(rect.left + stop + padding + completedWidth,
|
||||
barRect.top,
|
||||
rect.right - padding,
|
||||
barRect.bottom);
|
||||
canvas.drawText(remainingText,
|
||||
barRect.centerX(),
|
||||
barRect.centerY() - yTextAdjust,
|
||||
paint);
|
||||
}
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
paint = new Paint();
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setAntiAlias(true);
|
||||
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
lowContrastTextColor = res.getColor(R.attr.lowContrastTextColor);
|
||||
mediumContrastTextColor = res.getColor(R.attr.mediumContrastTextColor);
|
||||
highContrastReverseTextColor = res.getColor(R.attr.highContrastReverseTextColor);
|
||||
tinyTextSize = getDimension(getContext(), R.dimen.tinyTextSize);
|
||||
}
|
||||
|
||||
public void setValues(List<Double> values)
|
||||
{
|
||||
this.values = values;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
public void setLabels(List<String> labels)
|
||||
{
|
||||
this.labels = labels;
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
public void setTargets(List<Double> targets)
|
||||
{
|
||||
this.targets = targets;
|
||||
requestLayout();
|
||||
}
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.activities.habits.list.views.toShortString
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
|
||||
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
|
||||
import org.isoron.uhabits.utils.StyledResources
|
||||
|
||||
class TargetChart : View {
|
||||
private var paint: Paint? = null
|
||||
private var baseSize = 0
|
||||
private var primaryColor = 0
|
||||
private var mediumContrastTextColor = 0
|
||||
private var highContrastReverseTextColor = 0
|
||||
private var lowContrastTextColor = 0
|
||||
private val rect = RectF()
|
||||
private val barRect = RectF()
|
||||
private var values = emptyList<Double>()
|
||||
private var labels = emptyList<String>()
|
||||
private var targets = emptyList<Double>()
|
||||
private var maxLabelSize = 0f
|
||||
private var tinyTextSize = 0f
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
primaryColor = color
|
||||
postInvalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (labels.size == 0) return
|
||||
maxLabelSize = 0f
|
||||
for (label in labels) {
|
||||
paint!!.textSize = tinyTextSize
|
||||
val len = paint!!.measureText(label)
|
||||
maxLabelSize = Math.max(maxLabelSize, len)
|
||||
}
|
||||
val marginTop = (height - baseSize * labels.size) / 2.0f
|
||||
rect[0f, marginTop, width.toFloat()] = marginTop + baseSize
|
||||
for (i in labels.indices) {
|
||||
drawRow(canvas, i, rect)
|
||||
rect.offset(0f, baseSize.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
var widthSpec = widthSpec
|
||||
var heightSpec = heightSpec
|
||||
baseSize = resources.getDimensionPixelSize(R.dimen.baseSize)
|
||||
val width = MeasureSpec.getSize(widthSpec)
|
||||
var height = labels.size * baseSize
|
||||
val params = layoutParams
|
||||
if (params != null && params.height == ViewGroup.LayoutParams.MATCH_PARENT) {
|
||||
height = MeasureSpec.getSize(heightSpec)
|
||||
if (labels.size > 0) baseSize = height / labels.size
|
||||
}
|
||||
heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
|
||||
setMeasuredDimension(widthSpec, heightSpec)
|
||||
}
|
||||
|
||||
private fun drawRow(canvas: Canvas, row: Int, rect: RectF) {
|
||||
val padding = dpToPixels(context, 4f)
|
||||
val round = dpToPixels(context, 2f)
|
||||
val stop = maxLabelSize + padding * 2
|
||||
paint!!.color = mediumContrastTextColor
|
||||
|
||||
// Draw label
|
||||
paint!!.textSize = tinyTextSize
|
||||
paint!!.textAlign = Paint.Align.RIGHT
|
||||
var yTextAdjust = (paint!!.descent() + paint!!.ascent()) / 2.0f
|
||||
canvas.drawText(
|
||||
labels[row],
|
||||
rect.left + stop - padding,
|
||||
rect.centerY() - yTextAdjust,
|
||||
paint!!
|
||||
)
|
||||
|
||||
// Draw background box
|
||||
paint!!.color = lowContrastTextColor
|
||||
barRect[rect.left + stop + padding, rect.top + baseSize * 0.05f, rect.right - padding] =
|
||||
rect.bottom - baseSize * 0.05f
|
||||
canvas.drawRoundRect(barRect, round, round, paint!!)
|
||||
var percentage = (values[row] / targets[row]).toFloat()
|
||||
percentage = Math.min(1.0f, percentage)
|
||||
|
||||
// Draw completed box
|
||||
var completedWidth = percentage * barRect.width()
|
||||
if (completedWidth > 0 && completedWidth < 2 * round) {
|
||||
completedWidth = 2 * round
|
||||
}
|
||||
val remainingWidth = barRect.width() - completedWidth
|
||||
paint!!.color = primaryColor
|
||||
barRect[barRect.left, barRect.top, barRect.left + completedWidth] = barRect.bottom
|
||||
canvas.drawRoundRect(barRect, round, round, paint!!)
|
||||
|
||||
// Draw values
|
||||
paint!!.color = Color.WHITE
|
||||
paint!!.textSize = tinyTextSize
|
||||
paint!!.textAlign = Paint.Align.CENTER
|
||||
yTextAdjust = (paint!!.descent() + paint!!.ascent()) / 2.0f
|
||||
val remaining = targets[row] - values[row]
|
||||
val completedText = values[row].toShortString()
|
||||
val remainingText = remaining.toShortString()
|
||||
if (completedWidth > paint!!.measureText(completedText) + 2 * padding) {
|
||||
paint!!.color = highContrastReverseTextColor
|
||||
canvas.drawText(
|
||||
completedText,
|
||||
barRect.centerX(),
|
||||
barRect.centerY() - yTextAdjust,
|
||||
paint!!
|
||||
)
|
||||
}
|
||||
if (remainingWidth > paint!!.measureText(remainingText) + 2 * padding) {
|
||||
paint!!.color = mediumContrastTextColor
|
||||
barRect[rect.left + stop + padding + completedWidth, barRect.top, rect.right - padding] =
|
||||
barRect.bottom
|
||||
canvas.drawText(
|
||||
remainingText,
|
||||
barRect.centerX(),
|
||||
barRect.centerY() - yTextAdjust,
|
||||
paint!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
paint = Paint()
|
||||
paint!!.textAlign = Paint.Align.CENTER
|
||||
paint!!.isAntiAlias = true
|
||||
val res = StyledResources(context)
|
||||
lowContrastTextColor = res.getColor(R.attr.lowContrastTextColor)
|
||||
mediumContrastTextColor = res.getColor(R.attr.mediumContrastTextColor)
|
||||
highContrastReverseTextColor = res.getColor(R.attr.highContrastReverseTextColor)
|
||||
tinyTextSize = getDimension(context, R.dimen.tinyTextSize)
|
||||
}
|
||||
|
||||
fun setValues(values: List<Double>) {
|
||||
this.values = values
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
fun setLabels(labels: List<String>) {
|
||||
this.labels = labels
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
fun setTargets(targets: List<Double>) {
|
||||
this.targets = targets
|
||||
requestLayout()
|
||||
}
|
||||
}
|
Loading…
Reference in new issue