Replace HistoryChart by new Kotlin implementation
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 30 KiB |
@@ -30,7 +30,7 @@ class AndroidCanvasTest : BaseViewTest() {
|
||||
val bmp = Bitmap.createBitmap(1000, 800, Bitmap.Config.ARGB_8888)
|
||||
val canvas = AndroidCanvas()
|
||||
canvas.context = testContext
|
||||
canvas.density = 2.0
|
||||
canvas.innerDensity = 2.0
|
||||
canvas.innerCanvas = android.graphics.Canvas(bmp)
|
||||
canvas.innerBitmap = bmp
|
||||
canvas.drawTestImage()
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.activities.common.views;
|
||||
|
||||
import androidx.test.ext.junit.runners.*;
|
||||
import androidx.test.filters.*;
|
||||
|
||||
import org.apache.commons.lang3.*;
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.core.models.*;
|
||||
import org.isoron.uhabits.core.ui.callbacks.*;
|
||||
import org.isoron.uhabits.core.utils.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.*;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@MediumTest
|
||||
public class HistoryChartTest extends BaseViewTest
|
||||
{
|
||||
private static final String BASE_PATH = "common/HistoryChart/";
|
||||
|
||||
private HistoryChart chart;
|
||||
|
||||
private Habit habit;
|
||||
|
||||
Timestamp today;
|
||||
|
||||
private OnToggleCheckmarkListener onToggleEntryListener;
|
||||
|
||||
@Override
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
super.setUp();
|
||||
|
||||
fixtures.purgeHabits(habitList);
|
||||
habit = fixtures.createLongHabit();
|
||||
today = new Timestamp(DateUtils.getStartOfToday());
|
||||
|
||||
Integer[] entries = habit
|
||||
.getComputedEntries()
|
||||
.getByInterval(today.minus(300), today)
|
||||
.stream()
|
||||
.map(Entry::getValue)
|
||||
.toArray(Integer[]::new);
|
||||
|
||||
chart = new HistoryChart(targetContext);
|
||||
chart.setSkipEnabled(true);
|
||||
chart.setEntries(ArrayUtils.toPrimitive(entries));
|
||||
chart.setColor(PaletteUtilsKt.toFixedAndroidColor(habit.getColor()));
|
||||
measureView(chart, dpToPixels(400), dpToPixels(200));
|
||||
|
||||
onToggleEntryListener = mock(OnToggleCheckmarkListener.class);
|
||||
chart.setOnToggleCheckmarkListener(onToggleEntryListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapDate_atInvalidLocations() throws Throwable
|
||||
{
|
||||
chart.setIsEditable(true);
|
||||
chart.tap(dpToPixels(118), dpToPixels(13)); // header
|
||||
chart.tap(dpToPixels(336), dpToPixels(60)); // tomorrow's square
|
||||
chart.tap(dpToPixels(370), dpToPixels(60)); // right axis
|
||||
verifyNoMoreInteractions(onToggleEntryListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapDate_withEditableView() throws Throwable
|
||||
{
|
||||
chart.setIsEditable(true);
|
||||
chart.tap(dpToPixels(340), dpToPixels(40));
|
||||
verify(onToggleEntryListener).onToggleEntry(today, Entry.SKIP);
|
||||
verifyNoMoreInteractions(onToggleEntryListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapDate_withEmptyHabit()
|
||||
{
|
||||
chart.setIsEditable(true);
|
||||
chart.setEntries(new int[]{});
|
||||
chart.tap(dpToPixels(340), dpToPixels(40));
|
||||
verify(onToggleEntryListener).onToggleEntry(today, Entry.YES_MANUAL);
|
||||
verifyNoMoreInteractions(onToggleEntryListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapDate_withReadOnlyView() throws Throwable
|
||||
{
|
||||
chart.setIsEditable(false);
|
||||
chart.tap(dpToPixels(340), dpToPixels(40));
|
||||
verifyNoMoreInteractions(onToggleEntryListener);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRender() throws Throwable
|
||||
{
|
||||
assertRenders(chart, BASE_PATH + "render.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRender_withDataOffset() throws Throwable
|
||||
{
|
||||
chart.onScroll(null, null, -dpToPixels(150), 0);
|
||||
chart.invalidate();
|
||||
|
||||
assertRenders(chart, BASE_PATH + "renderDataOffset.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRender_withDifferentSize() throws Throwable
|
||||
{
|
||||
measureView(chart, dpToPixels(200), dpToPixels(200));
|
||||
assertRenders(chart, BASE_PATH + "renderDifferentSize.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRender_withTransparentBackground() throws Throwable
|
||||
{
|
||||
chart.setIsBackgroundTransparent(true);
|
||||
assertRenders(chart, BASE_PATH + "renderTransparent.png");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.test.filters.MediumTest
|
||||
import org.isoron.uhabits.BaseViewTest
|
||||
import org.isoron.uhabits.R
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardPresenter
|
||||
import org.isoron.uhabits.core.ui.views.LightTheme
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -47,7 +48,8 @@ class HistoryCardViewTest : BaseViewTest() {
|
||||
HistoryCardPresenter().present(
|
||||
habit = habit,
|
||||
firstWeekday = 1,
|
||||
isSkipEnabled = false
|
||||
isSkipEnabled = false,
|
||||
theme = LightTheme(),
|
||||
)
|
||||
)
|
||||
measureView(view, 800f, 600f)
|
||||
|
||||
@@ -29,19 +29,24 @@ import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
|
||||
|
||||
class AndroidCanvas : Canvas {
|
||||
|
||||
lateinit var innerCanvas: android.graphics.Canvas
|
||||
lateinit var context: Context
|
||||
|
||||
lateinit var innerCanvas: android.graphics.Canvas
|
||||
var innerBitmap: Bitmap? = null
|
||||
var density = 1.0
|
||||
var innerDensity = 1.0
|
||||
var innerWidth = 0
|
||||
var innerHeight = 0
|
||||
|
||||
var paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
}
|
||||
var textPaint = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
var textBounds = Rect()
|
||||
|
||||
private fun Double.toDp() = (this * density).toFloat()
|
||||
private fun Double.toDp() = (this * innerDensity).toFloat()
|
||||
|
||||
override fun setColor(color: Color) {
|
||||
paint.color = color.toInt()
|
||||
@@ -73,6 +78,25 @@ class AndroidCanvas : Canvas {
|
||||
rect(x, y, width, height)
|
||||
}
|
||||
|
||||
override fun fillRoundRect(
|
||||
x: Double,
|
||||
y: Double,
|
||||
width: Double,
|
||||
height: Double,
|
||||
cornerRadius: Double,
|
||||
) {
|
||||
paint.style = Paint.Style.FILL
|
||||
innerCanvas.drawRoundRect(
|
||||
x.toDp(),
|
||||
y.toDp(),
|
||||
(x + width).toDp(),
|
||||
(y + height).toDp(),
|
||||
cornerRadius.toDp(),
|
||||
cornerRadius.toDp(),
|
||||
paint,
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
|
||||
paint.style = Paint.Style.STROKE
|
||||
rect(x, y, width, height)
|
||||
@@ -89,11 +113,11 @@ class AndroidCanvas : Canvas {
|
||||
}
|
||||
|
||||
override fun getHeight(): Double {
|
||||
return innerCanvas.height / density
|
||||
return innerHeight / innerDensity
|
||||
}
|
||||
|
||||
override fun getWidth(): Double {
|
||||
return innerCanvas.width / density
|
||||
return innerWidth / innerDensity
|
||||
}
|
||||
|
||||
override fun setFont(font: Font) {
|
||||
|
||||
@@ -31,7 +31,7 @@ import android.widget.Scroller
|
||||
*/
|
||||
class AndroidDataView(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
attrs: AttributeSet? = null,
|
||||
) : AndroidView<DataView>(context, attrs),
|
||||
GestureDetector.OnGestureListener,
|
||||
ValueAnimator.AnimatorUpdateListener {
|
||||
@@ -99,7 +99,7 @@ class AndroidDataView(
|
||||
}
|
||||
|
||||
private fun updateDataOffset() {
|
||||
var newDataOffset: Int = scroller.currX / (view.dataColumnWidth * canvas.density).toInt()
|
||||
var newDataOffset: Int = scroller.currX / (view.dataColumnWidth * canvas.innerDensity).toInt()
|
||||
newDataOffset = Math.max(0, newDataOffset)
|
||||
if (newDataOffset != view.dataOffset) {
|
||||
view.dataOffset = newDataOffset
|
||||
|
||||
@@ -28,7 +28,7 @@ class AndroidTestView(context: Context, attrs: AttributeSet) : android.view.View
|
||||
override fun onDraw(canvas: android.graphics.Canvas) {
|
||||
this.canvas.context = context
|
||||
this.canvas.innerCanvas = canvas
|
||||
this.canvas.density = resources.displayMetrics.density.toDouble()
|
||||
this.canvas.innerDensity = resources.displayMetrics.density.toDouble()
|
||||
this.canvas.drawTestImage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import android.util.AttributeSet
|
||||
|
||||
open class AndroidView<T : View>(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
attrs: AttributeSet? = null,
|
||||
) : android.view.View(context, attrs) {
|
||||
|
||||
lateinit var view: T
|
||||
@@ -33,7 +33,9 @@ open class AndroidView<T : View>(
|
||||
override fun onDraw(canvas: android.graphics.Canvas) {
|
||||
this.canvas.context = context
|
||||
this.canvas.innerCanvas = canvas
|
||||
this.canvas.density = resources.displayMetrics.density.toDouble()
|
||||
this.canvas.innerWidth = width
|
||||
this.canvas.innerHeight = height
|
||||
this.canvas.innerDensity = resources.displayMetrics.density.toDouble()
|
||||
view.draw(this.canvas)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,17 @@ import androidx.appcompat.app.*;
|
||||
import android.util.*;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.activities.common.views.*;
|
||||
import org.isoron.uhabits.core.commands.*;
|
||||
import org.isoron.uhabits.core.models.*;
|
||||
import org.isoron.uhabits.core.preferences.*;
|
||||
import org.isoron.uhabits.core.tasks.*;
|
||||
import org.isoron.uhabits.core.ui.callbacks.*;
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.*;
|
||||
import org.isoron.uhabits.core.ui.views.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
import org.jetbrains.annotations.*;
|
||||
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
import java.util.*;
|
||||
|
||||
public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
implements DialogInterface.OnClickListener, CommandRunner.Listener
|
||||
@@ -92,31 +92,31 @@ public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
commandRunner = app.getComponent().getCommandRunner();
|
||||
prefs = app.getComponent().getPreferences();
|
||||
|
||||
historyChart = new HistoryChart(context);
|
||||
historyChart.setOnToggleCheckmarkListener(onToggleCheckmarkListener);
|
||||
historyChart.setFirstWeekday(prefs.getFirstWeekday());
|
||||
historyChart.setSkipEnabled(prefs.isSkipEnabled());
|
||||
|
||||
if (savedInstanceState != null)
|
||||
{
|
||||
long id = savedInstanceState.getLong("habit", -1);
|
||||
if (id > 0) this.habit = habitList.getById(id);
|
||||
historyChart.onRestoreInstanceState(
|
||||
savedInstanceState.getParcelable("historyChart"));
|
||||
}
|
||||
|
||||
int padding =
|
||||
(int) getDimension(getContext(), R.dimen.history_editor_padding);
|
||||
|
||||
historyChart.setPadding(padding, 0, padding, 0);
|
||||
historyChart.setIsEditable(true);
|
||||
// historyChart = new HistoryChart(context);
|
||||
// historyChart.setOnToggleCheckmarkListener(onToggleCheckmarkListener);
|
||||
// historyChart.setFirstWeekday(prefs.getFirstWeekday());
|
||||
// historyChart.setSkipEnabled(prefs.isSkipEnabled());
|
||||
|
||||
// if (savedInstanceState != null)
|
||||
// {
|
||||
// long id = savedInstanceState.getLong("habit", -1);
|
||||
// if (id > 0) this.habit = habitList.getById(id);
|
||||
// historyChart.onRestoreInstanceState(
|
||||
// savedInstanceState.getParcelable("historyChart"));
|
||||
// }
|
||||
//
|
||||
// int padding =
|
||||
// (int) getDimension(getContext(), R.dimen.history_editor_padding);
|
||||
//
|
||||
// historyChart.setPadding(padding, 0, padding, 0);
|
||||
// historyChart.setIsEditable(true);
|
||||
//
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
builder
|
||||
.setTitle(R.string.history)
|
||||
.setView(historyChart)
|
||||
// .setView(historyChart)
|
||||
.setPositiveButton(android.R.string.ok, this);
|
||||
|
||||
//
|
||||
return builder.create();
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@ public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState)
|
||||
{
|
||||
outState.putLong("habit", habit.getId());
|
||||
outState.putParcelable("historyChart", historyChart.onSaveInstanceState());
|
||||
// outState.putLong("habit", habit.getId());
|
||||
// outState.putParcelable("historyChart", historyChart.onSaveInstanceState());
|
||||
}
|
||||
|
||||
public void setOnToggleCheckmarkListener(@NonNull OnToggleCheckmarkListener onToggleCheckmarkListener)
|
||||
@@ -174,7 +174,7 @@ public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
|
||||
private class RefreshTask implements Task
|
||||
{
|
||||
public int[] checkmarks;
|
||||
public List<HistoryChart.Square> checkmarks;
|
||||
|
||||
@Override
|
||||
public void doInBackground()
|
||||
@@ -182,9 +182,10 @@ public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
HistoryCardViewModel model = new HistoryCardPresenter().present(
|
||||
habit,
|
||||
prefs.getFirstWeekday(),
|
||||
prefs.isSkipEnabled()
|
||||
prefs.isSkipEnabled(),
|
||||
new LightTheme()
|
||||
);
|
||||
checkmarks = model.getEntries();
|
||||
checkmarks = model.getSeries();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -194,9 +195,9 @@ public class HistoryEditorDialog extends AppCompatDialogFragment
|
||||
return;
|
||||
|
||||
int color = PaletteUtilsKt.toThemedAndroidColor(habit.getColor(), getContext());
|
||||
historyChart.setColor(color);
|
||||
historyChart.setEntries(checkmarks);
|
||||
historyChart.setNumerical(habit.isNumerical());
|
||||
// historyChart.setColor(color);
|
||||
// historyChart.setEntries(checkmarks);
|
||||
// historyChart.setNumerical(habit.isNumerical());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Álinson Santos Xavier <isoron@gmail.com>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.activities.common.views;
|
||||
|
||||
import android.content.*;
|
||||
import android.graphics.*;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint.*;
|
||||
import android.util.*;
|
||||
import android.view.*;
|
||||
|
||||
import androidx.annotation.*;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.isoron.uhabits.*;
|
||||
import org.isoron.uhabits.core.models.*;
|
||||
import org.isoron.uhabits.core.ui.callbacks.*;
|
||||
import org.isoron.uhabits.core.utils.*;
|
||||
import org.isoron.uhabits.utils.*;
|
||||
import org.jetbrains.annotations.*;
|
||||
|
||||
import java.text.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.isoron.uhabits.utils.InterfaceUtils.*;
|
||||
import static org.isoron.uhabits.core.models.Entry.*;
|
||||
|
||||
public class HistoryChart extends ScrollableChart
|
||||
{
|
||||
private int[] checkmarks;
|
||||
|
||||
private Paint pSquareBg, pSquareFg, pTextHeader;
|
||||
|
||||
private float squareSpacing;
|
||||
|
||||
private float squareTextOffset;
|
||||
|
||||
private float headerTextOffset;
|
||||
|
||||
private float columnWidth;
|
||||
|
||||
private float columnHeight;
|
||||
|
||||
private int nColumns;
|
||||
|
||||
private SimpleDateFormat dfMonth;
|
||||
|
||||
private SimpleDateFormat dfYear;
|
||||
|
||||
private Calendar baseDate;
|
||||
|
||||
private int nDays;
|
||||
|
||||
/**
|
||||
* 0-based-position of today in the column
|
||||
*/
|
||||
private int todayPositionInColumn;
|
||||
|
||||
private int colors[];
|
||||
|
||||
private int textColors[];
|
||||
|
||||
private RectF baseLocation;
|
||||
|
||||
private int primaryColor;
|
||||
|
||||
private boolean isBackgroundTransparent;
|
||||
|
||||
private int reverseTextColor;
|
||||
|
||||
private int backgroundColor;
|
||||
|
||||
private boolean isEditable;
|
||||
|
||||
private String previousMonth;
|
||||
|
||||
private String previousYear;
|
||||
|
||||
private float headerOverflow = 0;
|
||||
|
||||
private boolean isNumerical = false;
|
||||
|
||||
private int firstWeekday = Calendar.SUNDAY;
|
||||
|
||||
@NonNull
|
||||
private OnToggleCheckmarkListener onToggleCheckmarkListener;
|
||||
|
||||
private boolean skipsEnabled;
|
||||
|
||||
public HistoryChart(Context context)
|
||||
{
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public HistoryChart(Context context, AttributeSet attrs)
|
||||
{
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e)
|
||||
{
|
||||
onSingleTapUp(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e)
|
||||
{
|
||||
float x, y;
|
||||
try
|
||||
{
|
||||
int pointerId = e.getPointerId(0);
|
||||
x = e.getX(pointerId);
|
||||
y = e.getY(pointerId);
|
||||
}
|
||||
catch (RuntimeException ex)
|
||||
{
|
||||
// Android often throws IllegalArgumentException here. Apparently,
|
||||
// the pointer id may become invalid shortly after calling
|
||||
// e.getPointerId.
|
||||
return false;
|
||||
}
|
||||
return tap(x, y);
|
||||
}
|
||||
|
||||
public boolean tap(float x, float y)
|
||||
{
|
||||
if (!isEditable) return false;
|
||||
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
|
||||
final Timestamp timestamp = positionToTimestamp(x, y);
|
||||
if (timestamp == null) return false;
|
||||
|
||||
Timestamp today = DateUtils.getTodayWithOffset();
|
||||
int newValue = YES_MANUAL;
|
||||
int offset = timestamp.daysUntil(today);
|
||||
if (offset < checkmarks.length)
|
||||
{
|
||||
if(skipsEnabled)
|
||||
newValue = Entry.Companion.nextToggleValueWithSkip(checkmarks[offset]);
|
||||
else
|
||||
newValue = Entry.Companion.nextToggleValueWithoutSkip(checkmarks[offset]);
|
||||
}
|
||||
|
||||
onToggleCheckmarkListener.onToggleEntry(timestamp, newValue);
|
||||
postInvalidate();
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
public void populateWithRandomData()
|
||||
{
|
||||
Random random = new Random();
|
||||
checkmarks = new int[100];
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
if (random.nextFloat() < 0.3) checkmarks[i] = 2;
|
||||
|
||||
for (int i = 0; i < 100 - 7; i++)
|
||||
{
|
||||
int count = 0;
|
||||
for (int j = 0; j < 7; j++)
|
||||
if (checkmarks[i + j] != 0) count++;
|
||||
|
||||
if (count >= 3) checkmarks[i] = Math.max(checkmarks[i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
public void setEntries(int[] checkmarks)
|
||||
{
|
||||
this.checkmarks = checkmarks;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setColor(int color)
|
||||
{
|
||||
this.primaryColor = color;
|
||||
initColors();
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setOnToggleCheckmarkListener(@NonNull OnToggleCheckmarkListener onToggleCheckmarkListener)
|
||||
{
|
||||
this.onToggleCheckmarkListener = onToggleCheckmarkListener;
|
||||
}
|
||||
|
||||
public void setNumerical(boolean numerical)
|
||||
{
|
||||
isNumerical = numerical;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setIsBackgroundTransparent(boolean isBackgroundTransparent)
|
||||
{
|
||||
this.isBackgroundTransparent = isBackgroundTransparent;
|
||||
initColors();
|
||||
}
|
||||
|
||||
public void setSkipEnabled(boolean value)
|
||||
{
|
||||
this.skipsEnabled = value;
|
||||
}
|
||||
|
||||
public void setIsEditable(boolean isEditable)
|
||||
{
|
||||
this.isEditable = isEditable;
|
||||
}
|
||||
|
||||
public void setFirstWeekday(int firstWeekday)
|
||||
{
|
||||
this.firstWeekday = firstWeekday;
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
protected void initPaints()
|
||||
{
|
||||
pTextHeader = new Paint();
|
||||
pTextHeader.setTextAlign(Align.LEFT);
|
||||
pTextHeader.setAntiAlias(true);
|
||||
|
||||
pSquareBg = new Paint();
|
||||
pSquareBg.setAntiAlias(true);
|
||||
|
||||
pSquareFg = new Paint();
|
||||
pSquareFg.setAntiAlias(true);
|
||||
pSquareFg.setTextAlign(Align.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas)
|
||||
{
|
||||
super.onDraw(canvas);
|
||||
|
||||
baseLocation.set(0, 0, columnWidth - squareSpacing,
|
||||
columnWidth - squareSpacing);
|
||||
baseLocation.offset(getPaddingLeft(), getPaddingTop());
|
||||
|
||||
headerOverflow = 0;
|
||||
previousMonth = "";
|
||||
previousYear = "";
|
||||
pTextHeader.setColor(textColors[1]);
|
||||
|
||||
updateDate();
|
||||
GregorianCalendar currentDate = (GregorianCalendar) baseDate.clone();
|
||||
|
||||
for (int column = 0; column < nColumns - 1; column++)
|
||||
{
|
||||
drawColumn(canvas, baseLocation, currentDate, column);
|
||||
baseLocation.offset(columnWidth, -columnHeight);
|
||||
}
|
||||
|
||||
drawAxis(canvas, baseLocation);
|
||||
}
|
||||
|
||||
@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 < 8) height = 200;
|
||||
float baseSize = height / 8.0f;
|
||||
setScrollerBucketSize((int) baseSize);
|
||||
|
||||
squareSpacing = dpToPixels(getContext(), 1.0f);
|
||||
float maxTextSize = getDimension(getContext(), R.dimen.regularTextSize);
|
||||
float textSize = height * 0.06f;
|
||||
textSize = Math.min(textSize, maxTextSize);
|
||||
|
||||
pSquareFg.setTextSize(textSize);
|
||||
pTextHeader.setTextSize(textSize);
|
||||
squareTextOffset = pSquareFg.getFontSpacing() * 0.4f;
|
||||
headerTextOffset = pTextHeader.getFontSpacing() * 0.3f;
|
||||
|
||||
float rightLabelWidth = getWeekdayLabelWidth() + headerTextOffset;
|
||||
float horizontalPadding = getPaddingRight() + getPaddingLeft();
|
||||
|
||||
columnWidth = baseSize;
|
||||
columnHeight = 8 * baseSize;
|
||||
nColumns =
|
||||
(int) ((width - rightLabelWidth - horizontalPadding) / baseSize) +
|
||||
1;
|
||||
|
||||
updateDate();
|
||||
}
|
||||
|
||||
private void drawAxis(Canvas canvas, RectF location)
|
||||
{
|
||||
float verticalOffset = pTextHeader.getFontSpacing() * 0.4f;
|
||||
|
||||
for (String day : DateUtils.getShortWeekdayNames(firstWeekday))
|
||||
{
|
||||
location.offset(0, columnWidth);
|
||||
canvas.drawText(day, location.left + headerTextOffset,
|
||||
location.centerY() + verticalOffset, pTextHeader);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawColumn(Canvas canvas,
|
||||
RectF location,
|
||||
GregorianCalendar date,
|
||||
int column)
|
||||
{
|
||||
drawColumnHeader(canvas, location, date);
|
||||
location.offset(0, columnWidth);
|
||||
|
||||
for (int j = 0; j < 7; j++)
|
||||
{
|
||||
if (!(column == nColumns - 2 && getDataOffset() == 0 &&
|
||||
j > todayPositionInColumn))
|
||||
{
|
||||
int checkmarkOffset =
|
||||
getDataOffset() * 7 + nDays - 7 * (column + 1) +
|
||||
todayPositionInColumn - j;
|
||||
drawSquare(canvas, location, date, checkmarkOffset);
|
||||
}
|
||||
|
||||
date.add(Calendar.DAY_OF_MONTH, 1);
|
||||
location.offset(0, columnWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawColumnHeader(Canvas canvas,
|
||||
RectF location,
|
||||
GregorianCalendar date)
|
||||
{
|
||||
String month = dfMonth.format(date.getTime());
|
||||
String year = dfYear.format(date.getTime());
|
||||
|
||||
String text = null;
|
||||
if (!month.equals(previousMonth)) text = previousMonth = month;
|
||||
else if (!year.equals(previousYear)) text = previousYear = year;
|
||||
|
||||
if (text != null)
|
||||
{
|
||||
canvas.drawText(text, location.left + headerOverflow,
|
||||
location.bottom - headerTextOffset, pTextHeader);
|
||||
headerOverflow +=
|
||||
pTextHeader.measureText(text) + columnWidth * 0.2f;
|
||||
}
|
||||
|
||||
headerOverflow = Math.max(0, headerOverflow - columnWidth);
|
||||
}
|
||||
|
||||
private void drawSquare(Canvas canvas,
|
||||
RectF location,
|
||||
GregorianCalendar date,
|
||||
int checkmarkOffset)
|
||||
{
|
||||
|
||||
int checkmark = 0;
|
||||
if (checkmarkOffset >= checkmarks.length)
|
||||
{
|
||||
pSquareBg.setColor(colors[0]);
|
||||
pSquareFg.setColor(textColors[1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
checkmark = checkmarks[checkmarkOffset];
|
||||
if(checkmark <= 0)
|
||||
{
|
||||
pSquareBg.setColor(colors[0]);
|
||||
pSquareFg.setColor(textColors[1]);
|
||||
}
|
||||
else if (isNumerical || checkmark == YES_MANUAL)
|
||||
{
|
||||
pSquareBg.setColor(colors[2]);
|
||||
pSquareFg.setColor(textColors[2]);
|
||||
}
|
||||
else
|
||||
{
|
||||
pSquareBg.setColor(colors[1]);
|
||||
pSquareFg.setColor(textColors[2]);
|
||||
}
|
||||
}
|
||||
|
||||
float round = dpToPixels(getContext(), 2);
|
||||
canvas.drawRoundRect(location, round, round, pSquareBg);
|
||||
|
||||
if (!isNumerical && checkmark == SKIP)
|
||||
{
|
||||
pSquareBg.setColor(backgroundColor);
|
||||
pSquareBg.setStrokeWidth(columnWidth * 0.025f);
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRect(location);
|
||||
float offset = - columnWidth;
|
||||
for (int k = 0; k < 10; k++)
|
||||
{
|
||||
offset += columnWidth / 5;
|
||||
canvas.drawLine(location.left + offset,
|
||||
location.bottom,
|
||||
location.right + offset,
|
||||
location.top,
|
||||
pSquareBg);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
String text = Integer.toString(date.get(Calendar.DAY_OF_MONTH));
|
||||
canvas.drawText(text, location.centerX(),
|
||||
location.centerY() + squareTextOffset, pSquareFg);
|
||||
}
|
||||
|
||||
private float getWeekdayLabelWidth()
|
||||
{
|
||||
float width = 0;
|
||||
|
||||
for (String w : DateUtils.getShortWeekdayNames(firstWeekday))
|
||||
width = Math.max(width, pSquareFg.measureText(w));
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
private void init()
|
||||
{
|
||||
isEditable = false;
|
||||
checkmarks = new int[0];
|
||||
onToggleCheckmarkListener = new OnToggleCheckmarkListener()
|
||||
{
|
||||
@Override
|
||||
public void onToggleEntry(@NotNull Timestamp timestamp, int value)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
initColors();
|
||||
initPaints();
|
||||
initDateFormats();
|
||||
initRects();
|
||||
}
|
||||
|
||||
private void initColors()
|
||||
{
|
||||
StyledResources res = new StyledResources(getContext());
|
||||
|
||||
if (isBackgroundTransparent)
|
||||
primaryColor = ColorUtils.setMinValue(primaryColor, 0.75f);
|
||||
|
||||
int red = Color.red(primaryColor);
|
||||
int green = Color.green(primaryColor);
|
||||
int blue = Color.blue(primaryColor);
|
||||
|
||||
backgroundColor = res.getColor(R.attr.cardBgColor);
|
||||
|
||||
if (isBackgroundTransparent)
|
||||
{
|
||||
colors = new int[3];
|
||||
colors[0] = Color.argb(16, 255, 255, 255);
|
||||
colors[1] = Color.argb(128, red, green, blue);
|
||||
colors[2] = primaryColor;
|
||||
|
||||
textColors = new int[3];
|
||||
textColors[0] = Color.WHITE;
|
||||
textColors[1] = Color.WHITE;
|
||||
textColors[2] = Color.WHITE;
|
||||
reverseTextColor = Color.WHITE;
|
||||
}
|
||||
else
|
||||
{
|
||||
colors = new int[3];
|
||||
colors[0] = res.getColor(R.attr.lowContrastTextColor);
|
||||
colors[1] = Color.argb(127, red, green, blue);
|
||||
colors[2] = primaryColor;
|
||||
|
||||
textColors = new int[3];
|
||||
textColors[0] = res.getColor(R.attr.lowContrastReverseTextColor);
|
||||
textColors[1] = res.getColor(R.attr.mediumContrastTextColor);
|
||||
textColors[2] = res.getColor(R.attr.highContrastReverseTextColor);
|
||||
reverseTextColor = res.getColor(R.attr.highContrastReverseTextColor);
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
baseLocation = new RectF();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Timestamp positionToTimestamp(float x, float y)
|
||||
{
|
||||
int col = (int) (x / columnWidth);
|
||||
int row = (int) (y / columnWidth);
|
||||
|
||||
if (row == 0) return null;
|
||||
if (col == nColumns - 1) return null;
|
||||
|
||||
int offset = col * 7 + (row - 1);
|
||||
Calendar date = (Calendar) baseDate.clone();
|
||||
date.add(Calendar.DAY_OF_YEAR, offset);
|
||||
|
||||
if (DateUtils.getStartOfDayWithOffset(date.getTimeInMillis()) >
|
||||
DateUtils.getStartOfTodayWithOffset()) return null;
|
||||
|
||||
return new Timestamp(date.getTimeInMillis());
|
||||
}
|
||||
|
||||
private void updateDate()
|
||||
{
|
||||
baseDate = DateUtils.getStartOfTodayCalendarWithOffset();
|
||||
baseDate.add(Calendar.DAY_OF_YEAR, -(getDataOffset() - 1) * 7);
|
||||
|
||||
nDays = (nColumns - 1) * 7;
|
||||
int realWeekday =
|
||||
DateUtils.getStartOfTodayCalendarWithOffset().get(Calendar.DAY_OF_WEEK);
|
||||
todayPositionInColumn =
|
||||
(7 + realWeekday - firstWeekday) % 7;
|
||||
|
||||
baseDate.add(Calendar.DAY_OF_YEAR, -nDays);
|
||||
baseDate.add(Calendar.DAY_OF_YEAR, -todayPositionInColumn);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import org.isoron.platform.time.JavaLocalDateFormatter
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardViewModel
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart
|
||||
import org.isoron.uhabits.databinding.ShowHabitHistoryBinding
|
||||
import org.isoron.uhabits.utils.toThemedAndroidColor
|
||||
import java.util.Locale
|
||||
|
||||
class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
|
||||
|
||||
@@ -37,14 +40,24 @@ class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(cont
|
||||
}
|
||||
|
||||
fun update(data: HistoryCardViewModel) {
|
||||
binding.historyChart.setFirstWeekday(data.firstWeekday)
|
||||
binding.historyChart.setSkipEnabled(data.isSkipEnabled)
|
||||
binding.historyChart.setEntries(data.entries)
|
||||
|
||||
val androidColor = data.color.toThemedAndroidColor(context)
|
||||
binding.title.setTextColor(androidColor)
|
||||
binding.historyChart.setColor(androidColor)
|
||||
if (data.isNumerical) {
|
||||
binding.historyChart.setNumerical(true)
|
||||
binding.chart.view = HistoryChart(
|
||||
today = data.today,
|
||||
paletteColor = data.color,
|
||||
theme = data.theme,
|
||||
dateFormatter = JavaLocalDateFormatter(Locale.getDefault())
|
||||
).apply {
|
||||
series = data.series
|
||||
}
|
||||
|
||||
// binding.historyChart.setFirstWeekday(data.firstWeekday)
|
||||
// binding.historyChart.setSkipEnabled(data.isSkipEnabled)
|
||||
// binding.historyChart.setEntries(data.entries)
|
||||
// binding.historyChart.setColor(androidColor)
|
||||
// if (data.isNumerical) {
|
||||
// binding.historyChart.setNumerical(true)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,15 @@ package org.isoron.uhabits.widgets
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import org.isoron.uhabits.activities.common.views.HistoryChart
|
||||
import org.isoron.platform.gui.AndroidDataView
|
||||
import org.isoron.platform.time.JavaLocalDateFormatter
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.ui.screens.habits.show.views.HistoryCardPresenter
|
||||
import org.isoron.uhabits.utils.toThemedAndroidColor
|
||||
import org.isoron.uhabits.core.ui.views.DarkTheme
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart
|
||||
import org.isoron.uhabits.core.utils.DateUtils
|
||||
import org.isoron.uhabits.widgets.views.GraphWidgetView
|
||||
import java.util.Locale
|
||||
|
||||
class HistoryWidget(
|
||||
context: Context,
|
||||
@@ -47,18 +51,25 @@ class HistoryWidget(
|
||||
habit = habit,
|
||||
isSkipEnabled = prefs.isSkipEnabled,
|
||||
firstWeekday = prefs.firstWeekday,
|
||||
theme = DarkTheme(),
|
||||
)
|
||||
(widgetView.dataView as HistoryChart).apply {
|
||||
setFirstWeekday(model.firstWeekday)
|
||||
setSkipEnabled(model.isSkipEnabled)
|
||||
setColor(model.color.toThemedAndroidColor(context))
|
||||
setEntries(model.entries)
|
||||
setNumerical(model.isNumerical)
|
||||
(widgetView.dataView as AndroidDataView).apply {
|
||||
(this.view as HistoryChart).series = model.series
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildView() =
|
||||
GraphWidgetView(context, HistoryChart(context)).apply {
|
||||
GraphWidgetView(
|
||||
context,
|
||||
AndroidDataView(context).apply {
|
||||
view = HistoryChart(
|
||||
today = DateUtils.getTodayWithOffset().toLocalDate(),
|
||||
paletteColor = habit.color,
|
||||
theme = DarkTheme(),
|
||||
dateFormatter = JavaLocalDateFormatter(Locale.getDefault())
|
||||
)
|
||||
}
|
||||
).apply {
|
||||
setTitle(habit.name)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
style="@style/CardHeader"
|
||||
android:text="@string/calendar"/>
|
||||
|
||||
<org.isoron.uhabits.activities.common.views.HistoryChart
|
||||
android:id="@+id/historyChart"
|
||||
<org.isoron.platform.gui.AndroidDataView
|
||||
android:id="@+id/chart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="160dp"/>
|
||||
|
||||
|
||||
BIN
android/uhabits-core/assets/test/views/BarChart/base.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/uhabits-core/assets/test/views/BarChart/offset.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/uhabits-core/assets/test/views/CanvasTest.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 424 B |
BIN
android/uhabits-core/assets/test/views/HabitListHeader/light.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
android/uhabits-core/assets/test/views/HistoryChart/base.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
android/uhabits-core/assets/test/views/HistoryChart/dark.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
android/uhabits-core/assets/test/views/HistoryChart/scroll.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
android/uhabits-core/assets/test/views/HistoryChart/small.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/uhabits-core/assets/test/views/Ring/draw1.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
@@ -34,6 +34,7 @@ interface Canvas {
|
||||
fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double)
|
||||
fun drawText(text: String, x: Double, y: Double)
|
||||
fun fillRect(x: Double, y: Double, width: Double, height: Double)
|
||||
fun fillRoundRect(x: Double, y: Double, width: Double, height: Double, cornerRadius: Double)
|
||||
fun drawRect(x: Double, y: Double, width: Double, height: Double)
|
||||
fun getHeight(): Double
|
||||
fun getWidth(): Double
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
package org.isoron.platform.gui
|
||||
|
||||
data class PaletteColor(val index: Int)
|
||||
|
||||
data class Color(
|
||||
val red: Double,
|
||||
val green: Double,
|
||||
@@ -48,4 +46,11 @@ data class Color(
|
||||
alpha * (1 - weight) + other.alpha * weight
|
||||
)
|
||||
}
|
||||
|
||||
fun contrast(other: Color): Double {
|
||||
val l1 = this.luminosity
|
||||
val l2 = other.luminosity
|
||||
val relativeLuminosity = (l1 + 0.05) / (l2 + 0.05)
|
||||
return if (relativeLuminosity >= 1) relativeLuminosity else 1 / relativeLuminosity
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
|
||||
import java.awt.RenderingHints.VALUE_FRACTIONALMETRICS_ON
|
||||
import java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON
|
||||
import java.awt.font.FontRenderContext
|
||||
import java.awt.geom.RoundRectangle2D
|
||||
import java.awt.image.BufferedImage
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -115,6 +116,25 @@ class JavaCanvas(
|
||||
g2d.fillRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
|
||||
}
|
||||
|
||||
override fun fillRoundRect(
|
||||
x: Double,
|
||||
y: Double,
|
||||
width: Double,
|
||||
height: Double,
|
||||
cornerRadius: Double
|
||||
) {
|
||||
g2d.fill(
|
||||
RoundRectangle2D.Double(
|
||||
toPixel(x).toDouble(),
|
||||
toPixel(y).toDouble(),
|
||||
toPixel(width).toDouble(),
|
||||
toPixel(height).toDouble(),
|
||||
toPixel(cornerRadius).toDouble(),
|
||||
toPixel(cornerRadius).toDouble(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawRect(x: Double, y: Double, width: Double, height: Double) {
|
||||
g2d.drawRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height))
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ class ShowHabitPresenter {
|
||||
habit = habit,
|
||||
firstWeekday = preferences.firstWeekday,
|
||||
isSkipEnabled = preferences.isSkipEnabled,
|
||||
theme = theme,
|
||||
),
|
||||
bar = BarCardPresenter().present(
|
||||
habit = habit,
|
||||
|
||||
@@ -19,16 +19,24 @@
|
||||
|
||||
package org.isoron.uhabits.core.ui.screens.habits.show.views
|
||||
|
||||
import org.isoron.platform.time.LocalDate
|
||||
import org.isoron.uhabits.core.models.Entry
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart
|
||||
import org.isoron.uhabits.core.ui.views.Theme
|
||||
import org.isoron.uhabits.core.utils.DateUtils
|
||||
import kotlin.math.max
|
||||
|
||||
data class HistoryCardViewModel(
|
||||
val color: PaletteColor,
|
||||
val entries: IntArray,
|
||||
val firstWeekday: Int,
|
||||
val isNumerical: Boolean,
|
||||
val isSkipEnabled: Boolean,
|
||||
val series: List<HistoryChart.Square>,
|
||||
val theme: Theme,
|
||||
val today: LocalDate,
|
||||
)
|
||||
|
||||
class HistoryCardPresenter {
|
||||
@@ -36,18 +44,37 @@ class HistoryCardPresenter {
|
||||
habit: Habit,
|
||||
firstWeekday: Int,
|
||||
isSkipEnabled: Boolean,
|
||||
theme: Theme,
|
||||
): HistoryCardViewModel {
|
||||
val today = DateUtils.getTodayWithOffset()
|
||||
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
|
||||
val entries =
|
||||
habit.computedEntries.getByInterval(oldest, today).map { it.value }.toIntArray()
|
||||
val entries = habit.computedEntries.getByInterval(oldest, today)
|
||||
val series = if (habit.isNumerical) {
|
||||
entries.map {
|
||||
Entry(it.timestamp, max(0, it.value))
|
||||
}.map {
|
||||
when (it.value) {
|
||||
0 -> HistoryChart.Square.OFF
|
||||
else -> HistoryChart.Square.ON
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries.map {
|
||||
when (it.value) {
|
||||
YES_MANUAL -> HistoryChart.Square.ON
|
||||
YES_AUTO -> HistoryChart.Square.DIMMED
|
||||
SKIP -> HistoryChart.Square.HATCHED
|
||||
else -> HistoryChart.Square.OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HistoryCardViewModel(
|
||||
entries = entries,
|
||||
color = habit.color,
|
||||
firstWeekday = firstWeekday,
|
||||
isNumerical = habit.isNumerical,
|
||||
isSkipEnabled = isSkipEnabled,
|
||||
today = today.toLocalDate(),
|
||||
theme = theme,
|
||||
series = series,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,53 +21,74 @@ package org.isoron.uhabits.core.ui.views
|
||||
|
||||
import org.isoron.platform.gui.Canvas
|
||||
import org.isoron.platform.gui.Color
|
||||
import org.isoron.platform.gui.DataView
|
||||
import org.isoron.platform.gui.TextAlign
|
||||
import org.isoron.platform.gui.View
|
||||
import org.isoron.platform.time.LocalDate
|
||||
import org.isoron.platform.time.LocalDateFormatter
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.round
|
||||
|
||||
class CalendarChart(
|
||||
class HistoryChart(
|
||||
var today: LocalDate,
|
||||
var color: Color,
|
||||
var paletteColor: PaletteColor,
|
||||
var theme: Theme,
|
||||
var dateFormatter: LocalDateFormatter
|
||||
) : View {
|
||||
) : DataView {
|
||||
|
||||
var padding = 5.0
|
||||
var backgroundColor = Color(0xFFFFFF)
|
||||
enum class Square {
|
||||
ON,
|
||||
OFF,
|
||||
DIMMED,
|
||||
HATCHED,
|
||||
}
|
||||
|
||||
// Data
|
||||
var series = listOf<Square>()
|
||||
|
||||
// Style
|
||||
var padding = 0.0
|
||||
var squareSpacing = 1.0
|
||||
var series = listOf<Double>()
|
||||
var scrollPosition = 0
|
||||
override var dataOffset = 0
|
||||
private var squareSize = 0.0
|
||||
|
||||
var lastPrintedMonth = ""
|
||||
var lastPrintedYear = ""
|
||||
|
||||
override val dataColumnWidth: Double
|
||||
get() = squareSpacing + squareSize
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val width = canvas.getWidth()
|
||||
val height = canvas.getHeight()
|
||||
canvas.setColor(backgroundColor)
|
||||
canvas.setColor(theme.cardBackgroundColor)
|
||||
canvas.fillRect(0.0, 0.0, width, height)
|
||||
squareSize = round((height - 2 * padding) / 8.0)
|
||||
canvas.setFontSize(height * 0.06)
|
||||
|
||||
val nColumns = floor((width - 2 * padding) / squareSize).toInt() - 2
|
||||
val todayWeekday = today.dayOfWeek
|
||||
val topLeftOffset = (nColumns - 1 + scrollPosition) * 7 + todayWeekday.index
|
||||
val topLeftOffset = (nColumns - 1 + dataOffset) * 7 + todayWeekday.index
|
||||
val topLeftDate = today.minus(topLeftOffset)
|
||||
|
||||
lastPrintedYear = ""
|
||||
lastPrintedMonth = ""
|
||||
|
||||
// Draw main columns
|
||||
repeat(nColumns) { column ->
|
||||
val topOffset = topLeftOffset - 7 * column
|
||||
val topDate = topLeftDate.plus(7 * column)
|
||||
drawColumn(canvas, column, topDate, topOffset)
|
||||
}
|
||||
|
||||
// Draw week day names
|
||||
canvas.setColor(theme.mediumContrastTextColor)
|
||||
repeat(7) { row ->
|
||||
val date = topLeftDate.plus(row)
|
||||
canvas.setTextAlign(TextAlign.LEFT)
|
||||
canvas.drawText(
|
||||
dateFormatter.shortWeekdayName(date),
|
||||
padding + nColumns * squareSize + padding,
|
||||
padding + nColumns * squareSize + squareSpacing * 3,
|
||||
padding + squareSize * (row + 1) + squareSize / 2
|
||||
)
|
||||
}
|
||||
@@ -97,22 +118,29 @@ class CalendarChart(
|
||||
}
|
||||
|
||||
private fun drawHeader(canvas: Canvas, column: Int, date: LocalDate) {
|
||||
if (date.day >= 8) return
|
||||
|
||||
canvas.setColor(theme.mediumContrastTextColor)
|
||||
if (date.month == 1) {
|
||||
canvas.drawText(
|
||||
date.year.toString(),
|
||||
padding + column * squareSize + squareSize / 2,
|
||||
padding + squareSize / 2
|
||||
)
|
||||
} else {
|
||||
canvas.drawText(
|
||||
dateFormatter.shortMonthName(date),
|
||||
padding + column * squareSize + squareSize / 2,
|
||||
padding + squareSize / 2
|
||||
)
|
||||
val monthText = dateFormatter.shortMonthName(date)
|
||||
val yearText = date.year.toString()
|
||||
val headerText: String
|
||||
when {
|
||||
monthText != lastPrintedMonth -> {
|
||||
headerText = monthText
|
||||
lastPrintedMonth = monthText
|
||||
}
|
||||
yearText != lastPrintedYear -> {
|
||||
headerText = yearText
|
||||
lastPrintedYear = headerText
|
||||
}
|
||||
else -> {
|
||||
headerText = ""
|
||||
}
|
||||
}
|
||||
canvas.setTextAlign(TextAlign.LEFT)
|
||||
canvas.drawText(
|
||||
headerText,
|
||||
padding + column * squareSize,
|
||||
padding + squareSize / 2
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawSquare(
|
||||
@@ -125,19 +153,46 @@ class CalendarChart(
|
||||
offset: Int
|
||||
) {
|
||||
|
||||
var value = if (offset >= series.size) 0.0 else series[offset]
|
||||
value = round(value * 5.0) / 5.0
|
||||
|
||||
var squareColor = color.blendWith(backgroundColor, 1 - value)
|
||||
var textColor = backgroundColor
|
||||
|
||||
if (value == 0.0) squareColor = theme.lowContrastTextColor
|
||||
if (squareColor.luminosity > 0.8)
|
||||
textColor = squareColor.blendWith(theme.highContrastTextColor, 0.5)
|
||||
val value = if (offset >= series.size) Square.OFF else series[offset]
|
||||
val squareColor: Color
|
||||
val color = theme.color(paletteColor.paletteIndex)
|
||||
when (value) {
|
||||
Square.ON -> {
|
||||
squareColor = color
|
||||
}
|
||||
Square.OFF -> {
|
||||
squareColor = theme.lowContrastTextColor
|
||||
}
|
||||
Square.DIMMED, Square.HATCHED -> {
|
||||
squareColor = color.blendWith(theme.cardBackgroundColor, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setColor(squareColor)
|
||||
canvas.fillRect(x, y, width, height)
|
||||
canvas.fillRoundRect(x, y, width, height, width * 0.15)
|
||||
|
||||
if (value == Square.HATCHED) {
|
||||
canvas.setStrokeWidth(0.75)
|
||||
canvas.setColor(theme.cardBackgroundColor)
|
||||
var k = width / 10
|
||||
repeat(5) {
|
||||
canvas.drawLine(x + k, y, x, y + k)
|
||||
canvas.drawLine(
|
||||
x + width - k,
|
||||
y + height,
|
||||
x + width,
|
||||
y + height - k
|
||||
)
|
||||
k += width / 5
|
||||
}
|
||||
}
|
||||
|
||||
val c1 = squareColor.contrast(theme.cardBackgroundColor)
|
||||
val c2 = squareColor.contrast(theme.mediumContrastTextColor)
|
||||
val textColor = if (c1 > c2) theme.cardBackgroundColor else theme.mediumContrastTextColor
|
||||
|
||||
canvas.setColor(textColor)
|
||||
canvas.setTextAlign(TextAlign.CENTER)
|
||||
canvas.drawText(date.day.toString(), x + width / 2, y + width / 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.components
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.isoron.platform.gui.assertRenders
|
||||
import org.isoron.platform.time.JavaLocalDateFormatter
|
||||
import org.isoron.platform.time.LocalDate
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.isoron.uhabits.core.ui.views.DarkTheme
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.DIMMED
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.HATCHED
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.OFF
|
||||
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.ON
|
||||
import org.isoron.uhabits.core.ui.views.LightTheme
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
class HistoryChartTest {
|
||||
val base = "views/HistoryChart"
|
||||
val fmt = JavaLocalDateFormatter(Locale.US)
|
||||
val theme = LightTheme()
|
||||
val view = HistoryChart(
|
||||
LocalDate(2015, 1, 25),
|
||||
PaletteColor(7),
|
||||
theme,
|
||||
fmt,
|
||||
).apply {
|
||||
series = listOf(
|
||||
2, // today
|
||||
2, 1, 2, 1, 2, 1, 2,
|
||||
2, 3, 3, 3, 3, 1, 2,
|
||||
2, 1, 2, 1, 2, 2, 1,
|
||||
1, 1, 1, 1, 2, 2, 2,
|
||||
1, 3, 3, 3, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 1, 1, 1, 1,
|
||||
2, 2, 2, 3, 3, 3, 1,
|
||||
1, 2, 1, 2, 1, 1, 2,
|
||||
1, 2, 1, 1, 1, 1, 2,
|
||||
2, 2, 2, 2, 2, 1, 1,
|
||||
1, 1, 2, 2, 1, 2, 1,
|
||||
1, 1, 1, 1, 2, 2, 2,
|
||||
).map {
|
||||
when (it) {
|
||||
3 -> HATCHED
|
||||
2 -> ON
|
||||
1 -> DIMMED
|
||||
else -> OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Label overflow
|
||||
// TODO: Transparent
|
||||
// TODO: onClick
|
||||
// TODO: HistoryEditorDialog
|
||||
// TODO: Remove excessive padding on widgets
|
||||
// TODO: First day of the week
|
||||
|
||||
@Test
|
||||
fun testDraw() = runBlocking {
|
||||
assertRenders(400, 200, "$base/base.png", view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDrawDifferentSize() = runBlocking {
|
||||
assertRenders(200, 200, "$base/small.png", view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDrawDarkTheme() = runBlocking {
|
||||
view.theme = DarkTheme()
|
||||
assertRenders(400, 200, "$base/dark.png", view)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDrawOffset() = runBlocking {
|
||||
view.dataOffset = 2
|
||||
assertRenders(400, 200, "$base/scroll.png", view)
|
||||
}
|
||||
}
|
||||