Replace BarChart by new Kotlin implementation

pull/707/head
Alinson S. Xavier 5 years ago
parent e97cdce467
commit 354c930d85

@ -1,83 +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.filters.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class BarChartTest extends BaseViewTest
{
private static final String BASE_PATH = "common/BarChart/";
private BarChart view;
@Override
@Before
public void setUp()
{
super.setUp();
Habit habit = fixtures.createLongNumericalHabit();
view = new BarChart(targetContext);
Timestamp today = DateUtils.getToday();
EntryList entries = habit.getComputedEntries();
view.setEntries(entries.getByInterval(today.minus(20), today));
view.setColor(PaletteUtilsKt.toThemedAndroidColor(habit.getColor(), targetContext));
measureView(view, dpToPixels(300), dpToPixels(200));
}
@Test
public void testRender() throws Throwable
{
assertRenders(view, BASE_PATH + "render.png");
}
@Test
public void testRender_withDataOffset() throws Throwable
{
view.onScroll(null, null, -dpToPixels(150), 0);
view.invalidate();
assertRenders(view, BASE_PATH + "renderDataOffset.png");
}
@Test
public void testRender_withDifferentSize() throws Throwable
{
measureView(view, dpToPixels(200), dpToPixels(200));
assertRenders(view, BASE_PATH + "renderDifferentSize.png");
}
@Test
public void testRender_withTransparentBackground() throws Throwable
{
view.setIsTransparencyEnabled(true);
assertRenders(view, BASE_PATH + "renderTransparent.png");
}
}

@ -25,8 +25,6 @@ import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
class AndroidCanvas : Canvas {
@ -107,7 +105,7 @@ class AndroidCanvas : Canvas {
}
override fun setFontSize(size: Double) {
textPaint.textSize = size.toDp() * 1.07f
textPaint.textSize = size.toDp()
}
override fun setStrokeWidth(size: Double) {
@ -156,14 +154,3 @@ class AndroidCanvas : Canvas {
return AndroidImage(bmp)
}
}
class AndroidCanvasTestView(context: Context, attrs: AttributeSet) : View(context, attrs) {
val canvas = AndroidCanvas()
override fun onDraw(canvas: android.graphics.Canvas) {
this.canvas.context = context
this.canvas.innerCanvas = canvas
this.canvas.density = resources.displayMetrics.density.toDouble()
this.canvas.drawTestImage()
}
}

@ -0,0 +1,109 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.widget.Scroller
/**
* An AndroidView that implements scrolling.
*/
class AndroidDataView(
context: Context,
attrs: AttributeSet,
) : AndroidView<DataView>(context, attrs),
GestureDetector.OnGestureListener,
ValueAnimator.AnimatorUpdateListener {
private val detector = GestureDetector(context, this)
private val scroller = Scroller(context, null, true)
private val scrollAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
addUpdateListener(this@AndroidDataView)
}
override fun onTouchEvent(event: MotionEvent?) = detector.onTouchEvent(event)
override fun onDown(e: MotionEvent?) = true
override fun onShowPress(e: MotionEvent?) = Unit
override fun onSingleTapUp(e: MotionEvent?) = false
override fun onLongPress(e: MotionEvent?) = Unit
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent?,
dx: Float,
dy: Float,
): Boolean {
if (Math.abs(dx) > Math.abs(dy)) {
val parent = parent
parent?.requestDisallowInterceptTouchEvent(true)
}
scroller.startScroll(
scroller.currX,
scroller.currY,
-dx.toInt(),
dy.toInt(),
0
)
scroller.computeScrollOffset()
updateDataOffset()
return true
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float,
): Boolean {
scroller.fling(scroller.currX, scroller.currY, velocityX.toInt() / 2, 0, 0, 10000, 0, 0)
invalidate()
scrollAnimator.duration = scroller.duration.toLong()
scrollAnimator.start()
return false
}
override fun onAnimationUpdate(animation: ValueAnimator?) {
if (!scroller.isFinished) {
scroller.computeScrollOffset()
updateDataOffset()
} else {
scrollAnimator.cancel()
}
}
fun resetDataOffset() {
scroller.finalX = 0
scroller.computeScrollOffset()
updateDataOffset()
}
private fun updateDataOffset() {
var newDataOffset: Int = scroller.currX / (view.dataColumnWidth * canvas.density).toInt()
newDataOffset = Math.max(0, newDataOffset)
if (newDataOffset != view.dataOffset) {
view.dataOffset = newDataOffset
postInvalidate()
}
}
}

@ -0,0 +1,34 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import android.content.Context
import android.util.AttributeSet
class AndroidTestView(context: Context, attrs: AttributeSet) : android.view.View(context, attrs) {
val canvas = AndroidCanvas()
override fun onDraw(canvas: android.graphics.Canvas) {
this.canvas.context = context
this.canvas.innerCanvas = canvas
this.canvas.density = resources.displayMetrics.density.toDouble()
this.canvas.drawTestImage()
}
}

@ -0,0 +1,39 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.platform.gui
import android.content.Context
import android.util.AttributeSet
open class AndroidView<T : View>(
context: Context,
attrs: AttributeSet,
) : android.view.View(context, attrs) {
lateinit var view: T
val canvas = AndroidCanvas()
override fun onDraw(canvas: android.graphics.Canvas) {
this.canvas.context = context
this.canvas.innerCanvas = canvas
this.canvas.density = resources.displayMetrics.density.toDouble()
view.draw(this.canvas)
}
}

@ -28,6 +28,9 @@ import androidx.core.content.ContextCompat
import org.isoron.uhabits.R
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.ThemeSwitcher
import org.isoron.uhabits.core.ui.views.DarkTheme
import org.isoron.uhabits.core.ui.views.LightTheme
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.inject.ActivityContext
import org.isoron.uhabits.inject.ActivityScope
@ -35,9 +38,11 @@ import org.isoron.uhabits.inject.ActivityScope
class AndroidThemeSwitcher
constructor(
@ActivityContext val context: Context,
preferences: Preferences
preferences: Preferences,
) : ThemeSwitcher(preferences) {
private var currentTheme: Theme = LightTheme()
override fun getSystemTheme(): Int {
if (SDK_INT < 29) return THEME_LIGHT
val uiMode = context.resources.configuration.uiMode
@ -48,17 +53,24 @@ constructor(
}
}
override fun getCurrentTheme(): Theme {
return currentTheme
}
override fun applyDarkTheme() {
currentTheme = DarkTheme()
context.setTheme(R.style.AppBaseThemeDark)
(context as Activity).window.navigationBarColor =
ContextCompat.getColor(context, R.color.grey_900)
}
override fun applyLightTheme() {
currentTheme = LightTheme()
context.setTheme(R.style.AppBaseTheme)
}
override fun applyPureBlackTheme() {
currentTheme = DarkTheme()
context.setTheme(R.style.AppBaseThemeDark_PureBlack)
(context as Activity).window.navigationBarColor =
ContextCompat.getColor(context, R.color.black)

@ -1,474 +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.util.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.activities.habits.list.views.*;
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 BarChart 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<Entry> entries;
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;
private double maxValue;
private int primaryColor;
public BarChart(Context context)
{
super(context);
init();
}
public BarChart(Context context, AttributeSet attrs)
{
super(context, attrs);
init();
}
public void populateWithRandomData()
{
Random random = new Random();
List<Entry> entries = new LinkedList<>();
Timestamp today = DateUtils.getToday();
for (int i = 1; i < 100; i++)
{
int value = random.nextInt(1000);
entries.add(new Entry(today.minus(i), value));
}
setEntries(entries);
}
public void setBucketSize(int bucketSize)
{
this.bucketSize = bucketSize;
postInvalidate();
}
public void setEntries(@NonNull List<Entry> entries)
{
this.entries = entries;
maxValue = 1.0;
for (Entry e : entries)
maxValue = Math.max(maxValue, e.getValue());
maxValue = Math.ceil(maxValue / 1000 * 1.05) * 1000;
postInvalidate();
}
public void setColor(int primaryColor)
{
StyledResources res = new StyledResources(getContext());
this.primaryColor = primaryColor;
postInvalidate();
}
public void setIsTransparencyEnabled(boolean enabled)
{
this.isTransparencyEnabled = enabled;
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 (entries == 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 >= entries.size()) continue;
double value = entries.get(offset).getValue();
Timestamp timestamp = entries.get(offset).getTimestamp();
int height = (int) (columnHeight * value / maxValue);
rect.set(0, 0, baseSize, height);
rect.offset(k * columnWidth + (columnWidth - baseSize) / 2,
paddingTop + columnHeight - height);
drawValue(activeCanvas, rect, value);
drawBar(activeCanvas, rect, value);
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) / 12;
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 = 12 * 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 drawBar(Canvas canvas, RectF rect, double value)
{
float margin = baseSize * 0.225f;
float round = dpToPixels(getContext(), 2);
int color = primaryColor;
rect.inset(-margin, 0);
setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
canvas.drawRoundRect(rect, round, round, pGraph);
rect.inset(margin, 0);
setModeOrColor(pGraph, XFERMODE_SRC, color);
canvas.drawRoundRect(rect, round, round, pGraph);
rect.set(rect.left,
rect.top + rect.height() / 2.0f,
rect.right,
rect.bottom);
canvas.drawRect(rect, pGraph);
if (isTransparencyEnabled) pGraph.setXfermode(XFERMODE_SRC);
}
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();
pText.setColor(textColor);
String text;
int year = calendar.get(Calendar.YEAR);
boolean shouldPrintYear = true;
if (yearText.equals(previousYearText)) shouldPrintYear = false;
if (skipYear > 0)
{
skipYear--;
shouldPrintYear = false;
}
if (bucketSize >= 365) shouldPrintYear = true;
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;
}
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.setColor(textColor);
pGrid.setColor(gridColor);
for (int i = 0; i < nRows; i++)
{
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 drawValue(Canvas canvas, RectF rect, double value)
{
if (value == 0) return;
int activeColor = primaryColor;
String label = NumberButtonViewKt.toShortString(value / 1000);
Rect rText = new Rect();
pText.getTextBounds(label, 0, label.length(), rText);
float offset = 0.5f * em;
float x = rect.centerX();
float y = rect.top - offset;
int cap = (int) (-0.1f * em);
rText.offset((int) x, (int) y);
rText.offset(-rText.width() / 2, 0);
rText.inset(3 * cap, cap);
setModeOrColor(pText, XFERMODE_CLEAR, backgroundColor);
canvas.drawRect(rText, pText);
setModeOrColor(pText, XFERMODE_SRC, activeColor);
canvas.drawText(label, x, y, pText);
}
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())
{
dfYear = new SimpleDateFormat("yyyy", Locale.US);
dfMonth = new SimpleDateFormat("MMM", Locale.US);
dfDay = new SimpleDateFormat("d", Locale.US);
return;
}
dfYear = DateExtensionsKt.toSimpleDataFormat("yyyy");
dfMonth = DateExtensionsKt.toSimpleDataFormat("MMM");
dfDay = DateExtensionsKt.toSimpleDataFormat("d");
}
private void initPaints()
{
pText = new Paint();
pText.setAntiAlias(true);
pText.setTextAlign(Paint.Align.CENTER);
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);
}
}

@ -43,13 +43,14 @@ import org.isoron.uhabits.intents.IntentFactory
class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
private val presenter = ShowHabitPresenter()
val presenter = ShowHabitPresenter()
private lateinit var commandRunner: CommandRunner
private lateinit var menu: ShowHabitMenu
private lateinit var view: ShowHabitView
private lateinit var habit: Habit
private lateinit var preferences: Preferences
private lateinit var themeSwitcher: AndroidThemeSwitcher
private val scope = CoroutineScope(Dispatchers.Main)
@ -61,7 +62,8 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
habit = habitList.getById(ContentUris.parseId(intent.data!!))!!
preferences = appComponent.preferences
commandRunner = appComponent.commandRunner
AndroidThemeSwitcher(this, preferences).apply()
themeSwitcher = AndroidThemeSwitcher(this, preferences)
themeSwitcher.apply()
view = ShowHabitView(this)
@ -134,6 +136,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener {
presenter.present(
habit = habit,
preferences = preferences,
theme = themeSwitcher.currentTheme,
)
)
}

@ -24,9 +24,12 @@ import android.view.LayoutInflater
import android.view.View
import android.widget.AdapterView
import android.widget.LinearLayout
import org.isoron.platform.time.JavaLocalDateFormatter
import org.isoron.uhabits.core.ui.screens.habits.show.views.BarCardViewModel
import org.isoron.uhabits.core.ui.views.BarChart
import org.isoron.uhabits.databinding.ShowHabitBarBinding
import org.isoron.uhabits.utils.toThemedAndroidColor
import java.util.Locale
class BarCardView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
@ -35,11 +38,16 @@ class BarCardView(context: Context, attrs: AttributeSet) : LinearLayout(context,
var onBoolSpinnerPosition: (position: Int) -> Unit = {}
fun update(data: BarCardViewModel) {
binding.barChart.setEntries(data.entries)
binding.barChart.setBucketSize(data.bucketSize)
val androidColor = data.color.toThemedAndroidColor(context)
binding.chart.view = BarChart(data.theme, JavaLocalDateFormatter(Locale.US)).apply {
series = mutableListOf(data.entries.map { it.value / 1000.0 })
colors = mutableListOf(theme.color(data.color.paletteIndex))
axis = data.entries.map { it.timestamp.toLocalDate() }
}
binding.chart.resetDataOffset()
binding.chart.postInvalidate()
binding.title.setTextColor(androidColor)
binding.barChart.setColor(androidColor)
if (data.isNumerical) {
binding.boolSpinner.visibility = GONE
} else {

@ -22,7 +22,7 @@
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<org.isoron.platform.gui.AndroidCanvasTestView
<org.isoron.platform.gui.AndroidTestView
android:layout_width="500dp"
android:layout_height="400dp" />
</LinearLayout>

@ -52,8 +52,8 @@
android:layout_alignParentTop="true"
android:text="@string/history" />
<org.isoron.uhabits.activities.common.views.BarChart
android:id="@+id/barChart"
<org.isoron.platform.gui.AndroidDataView
android:id="@+id/chart"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_below="@id/title"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

@ -19,6 +19,11 @@
package org.isoron.platform.gui
interface Component {
interface View {
fun draw(canvas: Canvas)
}
interface DataView : View {
var dataOffset: Int
val dataColumnWidth: Double
}

@ -19,6 +19,7 @@
package org.isoron.platform.time
import org.isoron.uhabits.core.models.Timestamp
import kotlin.math.abs
import kotlin.math.ceil
@ -32,15 +33,6 @@ enum class DayOfWeek(val index: Int) {
SATURDAY(6),
}
data class Timestamp(val millisSince1970: Long) {
val localDate: LocalDate
get() {
val millisSince2000 = millisSince1970 - 946684800000
val daysSince2000 = millisSince2000 / 86400000
return LocalDate(daysSince2000.toInt())
}
}
data class LocalDate(val daysSince2000: Int) {
var yearCache = -1

@ -20,9 +20,11 @@
package org.isoron.uhabits.core.models;
import org.apache.commons.lang3.builder.*;
import org.isoron.platform.time.LocalDate;
import org.isoron.uhabits.core.utils.*;
import org.jetbrains.annotations.*;
import java.time.*;
import java.util.*;
import kotlin.*;
@ -66,6 +68,13 @@ public final class Timestamp implements Comparable<Timestamp>
return unixTime;
}
public LocalDate toLocalDate()
{
long millisSince2000 = unixTime - 946684800000L;
int daysSince2000 = (int) (millisSince2000 / 86400000);
return new LocalDate(daysSince2000);
}
/**
* Returns -1 if this timestamp is older than the given timestamp, 1 if this
* timestamp is newer, or zero if they are equal.

@ -22,6 +22,7 @@ package org.isoron.uhabits.core.ui;
import androidx.annotation.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.views.*;
public abstract class ThemeSwitcher
{
@ -59,6 +60,8 @@ public abstract class ThemeSwitcher
public abstract int getSystemTheme();
public abstract Theme getCurrentTheme();
public boolean isNightMode()
{
int systemTheme = getSystemTheme();

@ -40,6 +40,7 @@ import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardPresente
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardViewModel
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.TargetCardViewModel
import org.isoron.uhabits.core.ui.views.Theme
data class ShowHabitViewModel(
val title: String = "",
@ -60,6 +61,7 @@ class ShowHabitPresenter {
fun present(
habit: Habit,
preferences: Preferences,
theme: Theme,
): ShowHabitViewModel {
return ShowHabitViewModel(
title = habit.name,
@ -100,6 +102,7 @@ class ShowHabitPresenter {
firstWeekday = preferences.firstWeekday,
boolSpinnerPosition = preferences.barCardBoolSpinnerPosition,
numericalSpinnerPosition = preferences.barCardNumericalSpinnerPosition,
theme = theme,
),
)
}

@ -23,9 +23,11 @@ import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.groupedSum
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
data class BarCardViewModel(
val theme: Theme,
val boolSpinnerPosition: Int,
val bucketSize: Int,
val color: PaletteColor,
@ -43,6 +45,7 @@ class BarCardPresenter {
firstWeekday: Int,
numericalSpinnerPosition: Int,
boolSpinnerPosition: Int,
theme: Theme,
): BarCardViewModel {
val bucketSize = if (habit.isNumerical) {
numericalBucketSizes[numericalSpinnerPosition]
@ -57,6 +60,7 @@ class BarCardPresenter {
isNumerical = habit.isNumerical,
)
return BarCardViewModel(
theme = theme,
entries = entries,
bucketSize = bucketSize,
color = habit.color,

@ -21,7 +21,7 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.DataView
import org.isoron.platform.gui.TextAlign
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
@ -32,12 +32,13 @@ import kotlin.math.round
class BarChart(
var theme: Theme,
var dateFormatter: LocalDateFormatter,
) : Component {
) : DataView {
// Data
var series = mutableListOf<List<Double>>()
var colors = mutableListOf<Color>()
var axis = listOf<LocalDate>()
override var dataOffset = 0
// Style
var paddingTop = 20.0
@ -50,6 +51,9 @@ class BarChart(
var nGridlines = 6
var backgroundColor = theme.cardBackgroundColor
override val dataColumnWidth: Double
get() = barWidth + barMargin * 2
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()
val height = canvas.getHeight()
@ -75,8 +79,11 @@ class BarChart(
barMargin
fun drawColumn(s: Int, c: Int) {
val dataColumn = nColumns - c - 1
val value = if (dataColumn < series[s].size) series[s][dataColumn] else 0.0
val dataColumn = nColumns - c - 1 + dataOffset
val value = when {
dataColumn < 0 || dataColumn >= series[s].size -> 0.0
else -> series[s][dataColumn]
}
if (value <= 0) return
val perc = value / maxValue
val barHeight = round(maxBarHeight * perc)
@ -142,12 +149,12 @@ class BarChart(
canvas.setTextAlign(TextAlign.CENTER)
var prevMonth = -1
var prevYear = -1
val isLargeInterval = (axis[0].distanceTo(axis[1]) > 300)
val isLargeInterval = axis.size < 2 || (axis[0].distanceTo(axis[1]) > 300)
for (c in 0 until nColumns) {
val x = barGroupOffset(c)
val dataColumn = nColumns - c - 1
if (dataColumn >= axis.size) continue
val dataColumn = nColumns - c - 1 + dataOffset
if (dataColumn < 0 || dataColumn >= axis.size) continue
val date = axis[dataColumn]
if (isLargeInterval) {
canvas.drawText(

@ -21,8 +21,8 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.TextAlign
import org.isoron.platform.gui.View
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
import kotlin.math.floor
@ -33,7 +33,7 @@ class CalendarChart(
var color: Color,
var theme: Theme,
var dateFormatter: LocalDateFormatter
) : Component {
) : View {
var padding = 5.0
var backgroundColor = Color(0xFFFFFF)

@ -21,15 +21,15 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import org.isoron.platform.gui.FontAwesome
import org.isoron.platform.gui.View
class CheckmarkButton(
private val value: Int,
private val color: Color,
private val theme: Theme
) : Component {
) : View {
override fun draw(canvas: Canvas) {
canvas.setFont(Font.FONT_AWESOME)
canvas.setFontSize(theme.smallTextSize * 1.5)

@ -20,8 +20,8 @@
package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import org.isoron.platform.gui.View
import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.LocalDateFormatter
@ -30,7 +30,7 @@ class HabitListHeader(
private val nButtons: Int,
private val theme: Theme,
private val fmt: LocalDateFormatter
) : Component {
) : View {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()

@ -21,8 +21,8 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.Font
import org.isoron.platform.gui.View
import java.lang.String.format
import kotlin.math.round
@ -52,7 +52,7 @@ class NumberButton(
val threshold: Double,
val units: String,
val theme: Theme
) : Component {
) : View {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()

@ -21,7 +21,7 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Canvas
import org.isoron.platform.gui.Color
import org.isoron.platform.gui.Component
import org.isoron.platform.gui.View
import java.lang.String.format
import kotlin.math.max
import kotlin.math.min
@ -33,7 +33,7 @@ class Ring(
val radius: Double,
val theme: Theme,
val label: Boolean = false
) : Component {
) : View {
override fun draw(canvas: Canvas) {
val width = canvas.getWidth()

@ -22,27 +22,23 @@ package org.isoron.uhabits.core.ui.views
import org.isoron.platform.gui.Color
abstract class Theme {
val toolbarColor = Color(0xffffff)
open val appBackgroundColor = Color(0xf4f4f4)
open val cardBackgroundColor = Color(0xFAFAFA)
open val headerBackgroundColor = Color(0xeeeeee)
open val headerBorderColor = Color(0xcccccc)
open val headerTextColor = Color(0x9E9E9E)
open val highContrastTextColor = Color(0x202020)
open val itemBackgroundColor = Color(0xffffff)
open val lowContrastTextColor = Color(0xe0e0e0)
open val mediumContrastTextColor = Color(0x9E9E9E)
open val statusBarBackgroundColor = Color(0x333333)
open val toolbarBackgroundColor = Color(0xf4f4f4)
open val toolbarColor = Color(0xffffff)
val lowContrastTextColor = Color(0xe0e0e0)
val mediumContrastTextColor = Color(0x9E9E9E)
val highContrastTextColor = Color(0x202020)
val cardBackgroundColor = Color(0xFFFFFF)
val appBackgroundColor = Color(0xf4f4f4)
val toolbarBackgroundColor = Color(0xf4f4f4)
val statusBarBackgroundColor = Color(0x333333)
val headerBackgroundColor = Color(0xeeeeee)
val headerBorderColor = Color(0xcccccc)
val headerTextColor = mediumContrastTextColor
val itemBackgroundColor = Color(0xffffff)
fun color(paletteIndex: Int): Color {
open fun color(paletteIndex: Int): Color {
return when (paletteIndex) {
0 -> Color(0xD32F2F)
1 -> Color(0x512DA8)
1 -> Color(0xE64A19)
2 -> Color(0xF57C00)
3 -> Color(0xFF8F00)
4 -> Color(0xF9A825)
@ -58,6 +54,9 @@ abstract class Theme {
14 -> Color(0x8E24AA)
15 -> Color(0xD81B60)
16 -> Color(0x5D4037)
17 -> Color(0x424242)
18 -> Color(0x757575)
19 -> Color(0x9E9E9E)
else -> Color(0x000000)
}
}
@ -68,3 +67,44 @@ abstract class Theme {
}
class LightTheme : Theme()
class DarkTheme : Theme() {
override val appBackgroundColor = Color(0x212121)
override val cardBackgroundColor = Color(0x303030)
override val headerBackgroundColor = Color(0x212121)
override val headerBorderColor = Color(0xcccccc)
override val headerTextColor = Color(0x9E9E9E)
override val highContrastTextColor = Color(0xF5F5F5)
override val itemBackgroundColor = Color(0xffffff)
override val lowContrastTextColor = Color(0x424242)
override val mediumContrastTextColor = Color(0x9E9E9E)
override val statusBarBackgroundColor = Color(0x333333)
override val toolbarBackgroundColor = Color(0xf4f4f4)
override val toolbarColor = Color(0xffffff)
override fun color(paletteIndex: Int): Color {
return when (paletteIndex) {
0 -> Color(0xEF9A9A)
1 -> Color(0xFFAB91)
2 -> Color(0xFFCC80)
3 -> Color(0xFFECB3)
4 -> Color(0xFFF59D)
5 -> Color(0xE6EE9C)
6 -> Color(0xC5E1A5)
7 -> Color(0x69F0AE)
8 -> Color(0x80CBC4)
9 -> Color(0x80DEEA)
10 -> Color(0x81D4FA)
11 -> Color(0x64B5F6)
12 -> Color(0x9FA8DA)
13 -> Color(0xB39DDB)
14 -> Color(0xCE93D8)
15 -> Color(0xF48FB1)
16 -> Color(0xBCAAA4)
17 -> Color(0xF5F5F5)
18 -> Color(0xE0E0E0)
19 -> Color(0x9E9E9E)
else -> Color(0xFFFFFF)
}
}
}

@ -29,7 +29,7 @@ import java.awt.image.BufferedImage.TYPE_INT_ARGB
class JavaCanvasTest {
@Test
fun run() = runBlocking {
assertRenders("components/CanvasTest.png", createCanvas(500, 400).apply { drawTestImage() })
assertRenders("views/CanvasTest.png", createCanvas(500, 400).apply { drawTestImage() })
}
}
@ -69,9 +69,9 @@ suspend fun assertRenders(
width: Int,
height: Int,
expectedPath: String,
component: Component,
view: View,
) {
val canvas = createCanvas(width, height)
component.draw(canvas)
view.draw(canvas)
assertRenders(expectedPath, canvas)
}

@ -17,19 +17,17 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.components
package org.isoron.uhabits.core.ui.views
import kotlinx.coroutines.runBlocking
import org.isoron.platform.gui.assertRenders
import org.isoron.platform.time.JavaLocalDateFormatter
import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.ui.views.BarChart
import org.isoron.uhabits.core.ui.views.LightTheme
import org.junit.Test
import java.util.Locale
class BarChartTest {
val base = "components/BarChart"
val base = "views/BarChart"
val today = LocalDate(2015, 1, 25)
val fmt = JavaLocalDateFormatter(Locale.US)
val theme = LightTheme()
@ -37,11 +35,20 @@ class BarChartTest {
val axis = (0..100).map { today.minus(it) }
val series1 = listOf(200.0, 0.0, 150.0, 137.0, 0.0, 0.0, 500.0, 30.0, 100.0, 0.0, 300.0)
@Test
fun testDraw() = runBlocking {
init {
component.axis = axis
component.series.add(series1)
component.colors.add(theme.color(8))
}
@Test
fun testDraw() = runBlocking {
assertRenders(300, 200, "$base/base.png", component)
}
@Test
fun testDrawWithOffset() = runBlocking {
component.dataOffset = 5
assertRenders(300, 200, "$base/offset.png", component)
}
}

Loading…
Cancel
Save