diff --git a/core/assets/test/components/BarChart/2-series.png b/core/assets/test/components/BarChart/2-series.png new file mode 100644 index 000000000..0dcdb126a Binary files /dev/null and b/core/assets/test/components/BarChart/2-series.png differ diff --git a/core/assets/test/components/BarChart/axis-monthly.png b/core/assets/test/components/BarChart/axis-monthly.png new file mode 100644 index 000000000..3a8633ec2 Binary files /dev/null and b/core/assets/test/components/BarChart/axis-monthly.png differ diff --git a/core/assets/test/components/BarChart/axis-weekly.png b/core/assets/test/components/BarChart/axis-weekly.png new file mode 100644 index 000000000..132cfce63 Binary files /dev/null and b/core/assets/test/components/BarChart/axis-weekly.png differ diff --git a/core/assets/test/components/BarChart/axis-yearly.png b/core/assets/test/components/BarChart/axis-yearly.png new file mode 100644 index 000000000..39ac4c552 Binary files /dev/null and b/core/assets/test/components/BarChart/axis-yearly.png differ diff --git a/core/assets/test/components/BarChart/base.png b/core/assets/test/components/BarChart/base.png new file mode 100644 index 000000000..8d917b769 Binary files /dev/null and b/core/assets/test/components/BarChart/base.png differ diff --git a/core/src/main/common/org/isoron/platform/time/Dates.kt b/core/src/main/common/org/isoron/platform/time/Dates.kt index 37ee103f3..ea102004f 100644 --- a/core/src/main/common/org/isoron/platform/time/Dates.kt +++ b/core/src/main/common/org/isoron/platform/time/Dates.kt @@ -46,10 +46,10 @@ data class LocalDate(val daysSince2000: Int) { var monthCache = -1 var dayCache = -1 - init { - if (daysSince2000 < 0) - throw IllegalArgumentException("$daysSince2000 < 0") - } +// init { +// if (daysSince2000 < 0) +// throw IllegalArgumentException("$daysSince2000 < 0") +// } constructor(year: Int, month: Int, day: Int) : this(daysSince2000(year, month, day)) diff --git a/core/src/main/common/org/isoron/uhabits/components/BarChart.kt b/core/src/main/common/org/isoron/uhabits/components/BarChart.kt new file mode 100644 index 000000000..c687e1ba6 --- /dev/null +++ b/core/src/main/common/org/isoron/uhabits/components/BarChart.kt @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2016-2019 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.components + +import org.isoron.platform.gui.* +import org.isoron.platform.time.* +import kotlin.math.* + +class BarChart(var theme: Theme, + var dateFormatter: LocalDateFormatter) : Component { + + // Data + var series = mutableListOf>() + var colors = mutableListOf() + var axis = listOf() + + // Style + var paddingTop = 20.0 + var paddingLeft = 5.0 + var paddingRight = 5.0 + var footerHeight = 40.0 + var barGroupMargin = 4.0 + var barMargin = 4.0 + var barWidth = 20.0 + var nGridlines = 6 + var backgroundColor = theme.cardBackgroundColor + + override fun draw(canvas: Canvas) { + val width = canvas.getWidth() + val height = canvas.getHeight() + + val n = series.size + val barGroupWidth = 2 * barGroupMargin + n * (barWidth + 2 * barMargin) + val safeWidth = width - paddingLeft - paddingRight + val nColumns = floor((safeWidth) / barGroupWidth).toInt() + val marginLeft = (safeWidth - nColumns * barGroupWidth) / 2 + val maxBarHeight = height - footerHeight - paddingTop + var maxValue = series.map { it.max()!! }.max()!! + maxValue = max(maxValue, 1.0) + + canvas.setColor(backgroundColor) + canvas.fillRect(0.0, 0.0, width, height) + + fun barGroupOffset(c: Int) = marginLeft + paddingLeft + + (nColumns - c - 1) * barGroupWidth + + fun barOffset(c: Int, s: Int) = barGroupOffset(c) + + barGroupMargin + + s * (barWidth + 2 * barMargin) + + barMargin + + fun drawColumn(s: Int, c: Int) { + val value = if (c < series[s].size) series[s][c] else 0.0 + val perc = value / maxValue + val barColorPerc = if (n > 1) 1.0 else round(perc / 0.20) * 0.20 + val barColor = theme.lowContrastTextColor.blendWith(colors[s], + barColorPerc) + val barHeight = round(maxBarHeight * perc) + val x = barOffset(c, s) + val y = height - footerHeight - barHeight + canvas.setColor(barColor) + val r = round(barWidth * 0.33) + canvas.fillRect(x, y + r, barWidth, barHeight - r) + canvas.fillRect(x + r, y, barWidth - 2 * r, r) + canvas.fillCircle(x + r, y + r, r) + canvas.fillCircle(x + barWidth - r, y + r, r) + canvas.setFontSize(theme.smallTextSize) + canvas.setTextAlign(TextAlign.CENTER) + canvas.setColor(backgroundColor) + canvas.fillRect(x - barMargin, + y - theme.smallTextSize * 1.25, + barWidth + 2 * barMargin, + theme.smallTextSize * 1.0) + canvas.setColor(theme.mediumContrastTextColor) + canvas.drawText(value.toShortString(), + x + barWidth / 2, + y - theme.smallTextSize * 0.80) + } + + fun drawSeries(s: Int) { + for (c in 0 until nColumns) drawColumn(s, c) + } + + fun drawMajorGrid() { + canvas.setStrokeWidth(1.0) + if (n > 1) { + canvas.setColor(backgroundColor.blendWith( + theme.lowContrastTextColor, + 0.5)) + for (c in 0 until nColumns - 1) { + val x = barGroupOffset(c) + canvas.drawLine(x, paddingTop, x, paddingTop + maxBarHeight) + } + } + for (k in 1 until nGridlines) { + val pct = 1.0 - (k.toDouble() / (nGridlines - 1)) + val y = paddingTop + maxBarHeight * pct + canvas.setColor(theme.lowContrastTextColor) + canvas.drawLine(0.0, y, width, y) + } + } + + fun drawFooter() { + val y = paddingTop + maxBarHeight + canvas.setColor(backgroundColor) + canvas.fillRect(0.0, y, width, height - y) + canvas.setColor(theme.lowContrastTextColor) + canvas.drawLine(0.0, y, width, y) + canvas.setColor(theme.mediumContrastTextColor) + canvas.setTextAlign(TextAlign.CENTER) + var prevMonth = -1 + var prevYear = -1 + val isLargeInterval = (axis[0].distanceTo(axis[1]) > 300) + + for (c in 0 until nColumns) { + val x = barGroupOffset(nColumns - c - 1) + val date = axis[nColumns - c - 1] + if(isLargeInterval) { + canvas.drawText(date.year.toString(), + x + barGroupWidth / 2, + y + theme.smallTextSize * 1.0) + } else { + if (date.month != prevMonth) { + canvas.drawText(dateFormatter.shortMonthName(date), + x + barGroupWidth / 2, + y + theme.smallTextSize * 1.0) + } else { + canvas.drawText(date.day.toString(), + x + barGroupWidth / 2, + y + theme.smallTextSize * 1.0) + } + if (date.year != prevYear) { + canvas.drawText(date.year.toString(), + x + barGroupWidth / 2, + y + theme.smallTextSize * 2.3) + } + } + prevMonth = date.month + prevYear = date.year + } + } + + drawMajorGrid() + for (k in 0 until n) drawSeries(k) + drawFooter() + } +} diff --git a/core/src/main/common/org/isoron/uhabits/components/CalendarChart.kt b/core/src/main/common/org/isoron/uhabits/components/CalendarChart.kt index 02ba821c1..20594df19 100644 --- a/core/src/main/common/org/isoron/uhabits/components/CalendarChart.kt +++ b/core/src/main/common/org/isoron/uhabits/components/CalendarChart.kt @@ -33,9 +33,7 @@ class CalendarChart(var today: LocalDate, var squareSpacing = 1.0 var series = listOf() var scrollPosition = 0 - private var squareSize = 0.0 - private var fontSize = 0.0 override fun draw(canvas: Canvas) { val width = canvas.getWidth() diff --git a/core/src/test/common/org/isoron/uhabits/BaseViewTest.kt b/core/src/test/common/org/isoron/uhabits/BaseViewTest.kt index 8ba9d83a7..c548614d7 100644 --- a/core/src/test/common/org/isoron/uhabits/BaseViewTest.kt +++ b/core/src/test/common/org/isoron/uhabits/BaseViewTest.kt @@ -58,7 +58,7 @@ open class BaseViewTest { } } else { actualImage.export(failedActualPath) - fail("Expected image file is missing.") + fail("Expected image file is missing. Actual image: $failedActualPath") } } } \ No newline at end of file diff --git a/core/src/test/common/org/isoron/uhabits/components/BarChartTest.kt b/core/src/test/common/org/isoron/uhabits/components/BarChartTest.kt new file mode 100644 index 000000000..9017f41cf --- /dev/null +++ b/core/src/test/common/org/isoron/uhabits/components/BarChartTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016-2019 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.components + +import org.isoron.* +import org.isoron.platform.time.* +import org.isoron.uhabits.* +import kotlin.test.* + +class BarChartTest : BaseViewTest() { + val base = "components/BarChart" + val today = LocalDate(2015, 1, 25) + val dailyAxis = (0..100).map { today.minus(it) } + val weeklyAxis = (0..100).map { today.minus(it * 7) } + val monthlyAxis = (0..100).map { today.minus(it * 30) } + val yearlyAxis = (0..100).map { today.minus(it * 365) } + val fmt = DependencyResolver.getDateFormatter(Locale.US) + val component = BarChart(theme, fmt) + + val series1 = listOf(200.0, 80.0, 150.0, 437.0, 50.0, 80.0, 420.0, + 350.0, 100.0, 375.0, 300.0, 50.0, 60.0, 350.0, + 125.0) + + val series2 = listOf(300.0, 500.0, 280.0, 50.0, 425.0, 300.0, 150.0, + 10.0, 50.0, 200.0, 230.0, 20.0, 60.0, 34.0, 100.0) + + init { + component.axis = dailyAxis + component.series.add(series1) + component.colors.add(theme.color(1)) + } + + @Test + fun testDraw() = asyncTest { + assertRenders(400, 200, "$base/base.png", component) + } + + @Test + fun testDrawWeeklyAxis() = asyncTest { + component.axis = weeklyAxis + assertRenders(400, 200, "$base/axis-weekly.png", component) + } + + @Test + fun testDrawMonthlyAxis() = asyncTest { + component.axis = monthlyAxis + assertRenders(400, 200, "$base/axis-monthly.png", component) + } + + @Test + fun testDrawYearlyAxis() = asyncTest { + component.axis = yearlyAxis + assertRenders(400, 200, "$base/axis-yearly.png", component) + } + + @Test + fun testDrawTwoSeries() = asyncTest { + component.series.add(series2) + component.colors.add(theme.color(3)) + assertRenders(400, 200, "$base/2-series.png", component) + } +} \ No newline at end of file diff --git a/ios/Application/Frontend/DetailScreenController.swift b/ios/Application/Frontend/DetailScreenController.swift index a5ebdb413..c9473c337 100644 --- a/ios/Application/Frontend/DetailScreenController.swift +++ b/ios/Application/Frontend/DetailScreenController.swift @@ -42,9 +42,32 @@ class DetailScreenController : UITableViewController { self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(self.onEditHabitClicked)) + cells.append(buildBarChartCell()) cells.append(buildHistoryChartCell()) } + func buildBarChartCell() -> UITableViewCell { + let today = LocalDate(year: 2019, month: 3, day: 15) + let axis = (0...365).map { today.minus(days: $0) } + let component = BarChart(theme: theme, + dateFormatter: IosLocalDateFormatter()) + component.axis = axis + let cell = UITableViewCell() + let view = ComponentView(frame: cell.frame, component: component) + for k in 0...0 { + var series = [KotlinDouble]() + for _ in 1...365 { + series.append(KotlinDouble(value: Double.random(in: 0...5000))) + } + component.series.add(series) + let color = (self.habit.color.index + Int32(k * 3)) % 16 + component.colors.add(theme.color(paletteIndex: color)) + } + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + cell.contentView.addSubview(view) + return cell + } + func buildHistoryChartCell() -> UITableViewCell { let component = CalendarChart(today: LocalDate(year: 2019, month: 3, day: 15), color: color,