diff --git a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt index 4e9def1..fea4f3e 100644 --- a/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt +++ b/app/src/main/java/com/dzeio/openhealth/ui/steps/StepsHomeFragment.kt @@ -5,10 +5,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager +import com.dzeio.charts.Entry import com.dzeio.openhealth.adapters.StepsAdapter import com.dzeio.openhealth.core.BaseFragment import com.dzeio.openhealth.databinding.FragmentStepsHomeBinding +import com.google.android.material.color.MaterialColors import dagger.hilt.android.AndroidEntryPoint +import java.text.DateFormat +import java.util.Date +import java.util.Locale @AndroidEntryPoint class StepsHomeFragment : @@ -41,19 +46,39 @@ class StepsHomeFragment : viewModel.items.observe(viewLifecycleOwner) { list -> adapter.set(list) +// chart.numberOfEntries = list.size / 2 + chart.numberOfLabels = 2 - val strings = ArrayList() - val values = ArrayList() +// chart.animation.enabled = false + chart.animation.refreshRate = 60 + chart.animation.duration = 500 - list.forEach { - strings.add(it.formatTimestamp()) - values.add(it.value) + chart.xAxis.labels.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorOnBackground + ) + chart.xAxis.labels.size = 32f + chart.yAxis.color = MaterialColors.getColor( + requireView(), + com.google.android.material.R.attr.colorPrimary + ) + + chart.list = list.reversed().map { + return@map Entry(it.timestamp.toDouble(), it.value.toFloat()) + } as ArrayList + + chart.xAxis.onValueFormat = onValueFormat@{ + val formatter = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + Locale.getDefault() + ) + return@onValueFormat formatter.format(Date(it.toLong())) } - chart.setBottomTextList(strings) - chart.setDataList( - values - ) +// chart.yAxis.max = (total / list.size).toInt() + + chart.refresh() } } } diff --git a/charts/src/main/java/com/dzeio/charts/Animation.kt b/charts/src/main/java/com/dzeio/charts/Animation.kt new file mode 100644 index 0000000..57694d6 --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/Animation.kt @@ -0,0 +1,51 @@ +package com.dzeio.charts + +import kotlin.math.abs + +data class Animation( + /** + * Enable / Disable the Chart Animations + */ + var enabled: Boolean = true, + + /** + * Number of milliseconds the animation is running before it ends + */ + var duration: Int = 1000, + + /** + * Number of updates per seconds + */ + var refreshRate: Int = 50 +) { + /** + * Update the value depending on the maximum obtainable value + * + * @param maxValue the maximum value the item can obtain + * @param targetValue the value you want to obtain at the end of the animation + * @param currentValue the current value + * + * @return the new updated value + */ + fun updateValue(maxValue: Float, targetValue: Float, currentValue: Float): Float { + if (!enabled) { + return targetValue + } + + val moveValue = (maxValue - targetValue) / refreshRate + + var result = targetValue + if (currentValue < targetValue) { + result = currentValue + moveValue + } else if (currentValue > targetValue) { + result = currentValue - moveValue + } + + if (abs(targetValue - currentValue) < moveValue) { + return targetValue + } + return result + } + + fun getDelay() = this.duration / this.refreshRate +} \ No newline at end of file diff --git a/charts/src/main/java/com/dzeio/charts/Entry.kt b/charts/src/main/java/com/dzeio/charts/Entry.kt new file mode 100644 index 0000000..0cac6b8 --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/Entry.kt @@ -0,0 +1,6 @@ +package com.dzeio.charts + +data class Entry( + val x: Double, + val y: Float +) \ No newline at end of file diff --git a/charts/src/main/java/com/dzeio/charts/Labels.kt b/charts/src/main/java/com/dzeio/charts/Labels.kt new file mode 100644 index 0000000..59d45e2 --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/Labels.kt @@ -0,0 +1,22 @@ +package com.dzeio.charts + +import android.graphics.Color +import android.graphics.Paint +import androidx.annotation.ColorInt + +class XAxisLabels { + + var size = 25f + + @ColorInt + var color: Int = Color.parseColor("#9B9A9B") + + fun build(): Paint { + return Paint().also { + it.isAntiAlias = true + it.color = color + it.textSize = size + it.textAlign = Paint.Align.CENTER + } + } +} \ No newline at end of file diff --git a/charts/src/main/java/com/dzeio/charts/axis/XAxis.kt b/charts/src/main/java/com/dzeio/charts/axis/XAxis.kt new file mode 100644 index 0000000..1931321 --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/axis/XAxis.kt @@ -0,0 +1,28 @@ +package com.dzeio.charts.axis + +import com.dzeio.charts.XAxisLabels + +class XAxis { + + var max: T? = null + var min: T? = null + + val labels = XAxisLabels() + + /** + * Number of entries displayed in the chart at the same time + */ + var entriesDisplayed = 5 + + /** + * Offset in the list + * + * WILL CHANGE AS SOON AS SCROLLING IS AVAILABLE + */ + var baseOffset = 0 + + var onValueFormat: (it: T) -> String = onValueFormat@{ + return@onValueFormat it.toString() + } + +} \ No newline at end of file diff --git a/charts/src/main/java/com/dzeio/charts/axis/YAxis.kt b/charts/src/main/java/com/dzeio/charts/axis/YAxis.kt new file mode 100644 index 0000000..1e67a66 --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/axis/YAxis.kt @@ -0,0 +1,12 @@ +package com.dzeio.charts.axis + +import android.graphics.Color +import androidx.annotation.ColorInt + +class YAxis { + var max: T? = null + var min: T? = null + + @ColorInt + var color = Color.parseColor("#FC496D") +} \ No newline at end of file diff --git a/charts/src/main/java/com/dzeio/charts/views/BarChartView.kt b/charts/src/main/java/com/dzeio/charts/views/BarChartView.kt index 201a03f..ae30a86 100644 --- a/charts/src/main/java/com/dzeio/charts/views/BarChartView.kt +++ b/charts/src/main/java/com/dzeio/charts/views/BarChartView.kt @@ -2,128 +2,156 @@ package com.dzeio.charts.views import android.content.Context import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint import android.graphics.Rect +import android.graphics.RectF import android.util.AttributeSet import android.util.Log -import android.view.View +import com.dzeio.charts.Animation +import com.dzeio.charts.Entry +import com.dzeio.charts.axis.XAxis +import com.dzeio.charts.axis.YAxis import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min class BarChartView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : - View(context, attrs) { + BaseChart(context, attrs) { companion object { const val TAG = "DzeioCharts/BarView" } /** - * Nunber of entries displayed at the same time + * Number of entries displayed at the same time */ - val numberOfEntries = 5 + private var zoom = 100f /** * Number of labels displayed at the same time */ - val numberOfLabels = 3 + var numberOfLabels = 3 /** * Spacing between entries */ - val spacing = 22 + var spacing = 22 - /** - * top margin from the canvas - */ - @Deprecated("Not needed anymore, Use the parent Padding/Margin") - private val topMargin: Int = 5 + val xAxis: XAxis = XAxis() + + val yAxis: YAxis = YAxis() + + val animation = Animation() private val textTopMargin = 5 private var barWidth: Int = 0 - private val textColor = Color.parseColor("#9B9A9B") - - private val foregroundColor = Color.parseColor("#FC496D") private val percentList: ArrayList = ArrayList() - private var targetPercentList: ArrayList = ArrayList() - private val textPaint: Paint = Paint().also { - it.isAntiAlias = true - it.color = textColor - it.textSize = 25f - it.textAlign = Paint.Align.CENTER - } - private val fgPaint: Paint = Paint().also { + /** + * value goes from 1 to 0 (1 at bottom, 0 at top) + */ + private var targetPercentList: ArrayList = ArrayList() + + private var fgPaint: Paint = Paint().also { it.isAntiAlias = true - it.color = foregroundColor + it.color = yAxis.color } - private val rect: Rect = Rect() + private val rect: RectF = RectF() + private var bottomTextDescent = 0 private var bottomTextHeight = 0 - private var bottomTextList: ArrayList? = ArrayList() + + private var movementOffset: Int = 0 + private val animator: Runnable = object : Runnable { override fun run() { var needNewFrame = false for (i in targetPercentList.indices) { - if (percentList[i] < targetPercentList[i]) { - percentList[i] = percentList[i] + 0.02f + val value = animation.updateValue(1f, targetPercentList[i], percentList[i]) + + if (value != percentList[i]) { needNewFrame = true - } else if (percentList[i] > targetPercentList[i]) { - percentList[i] = percentList[i] - 0.02f - needNewFrame = true - } - if (abs(targetPercentList[i] - percentList[i]) < 0.02f) { - percentList[i] = targetPercentList[i] + percentList[i] = value } } if (needNewFrame) { - postDelayed(this, 20) + postDelayed(this, animation.getDelay().toLong()) } invalidate() } } - /** - * dataList will be reset when called is method. - * - * @param bottomStringList The String ArrayList in the bottom. - */ - fun setBottomTextList(bottomStringList: ArrayList?) { - barWidth = measuredWidth / numberOfEntries - spacing - bottomTextList = bottomStringList + var list: ArrayList = arrayListOf() + + private var bottomTexts: ArrayList = arrayListOf() + +// init { +// val mockList = ArrayList() +// for (i in 0 until 25) { +// mockList.add(Entry(i.toDouble(), i.toFloat())) +// } +// +// list = mockList +// +// this.refresh() +// } + + fun refresh() { + val r = Rect() + + //// prepare bottom texts bottomTextDescent = 0 - for (s in bottomTextList!!) { - textPaint.getTextBounds(s, 0, s.length, r) + bottomTextHeight = 0 + bottomTexts = arrayListOf() + + //// prepare values + + // set the bar Width (also handle div by 0) + barWidth = measuredWidth / max(min(list.size, getDisplayedEntries()), 1) - spacing + + // calculate max depending on the maximum value displayed or set in the yAxis params + val max: Float = if (yAxis.max != null) yAxis.max!! else { + var calculatedMax = 0f + for (entry in list.subList(this.getXOffset(), getDisplayedEntries() + this.getXOffset())) { + if (entry.y > calculatedMax) calculatedMax = entry.y + } + calculatedMax + } + + // make sure the target list +// Log.d(TAG, list.size.toString()) + targetPercentList = arrayListOf() + + + for ((i, item) in list.withIndex()) { + //// Process bottom texts + val text = xAxis.onValueFormat(item.x) + bottomTexts.add(text) + + // get Text boundaries + xAxis.labels.build().getTextBounds(text, 0, text.length, r) + + // get height of text if (bottomTextHeight < r.height()) { bottomTextHeight = r.height() } - Log.d(TAG, measuredWidth.toString()) -// if (autoSetWidth && barWidth < r.width()) { -// barWidth = r.width() -// } - if (bottomTextDescent < abs(r.bottom)) { - bottomTextDescent = abs(r.bottom) + + // get text descent + val descent = abs(r.bottom) + if (bottomTextDescent < descent) { + bottomTextDescent = descent } - } - postInvalidate() - } - /** - * @param list The ArrayList of Integer with the range of [0-max]. - */ - fun setDataList(list: ArrayList) { - barWidth = measuredWidth / numberOfEntries - spacing - // Calculate max - val max = list.reduce { acc, i -> if (acc > i) return@reduce acc else return@reduce i } + //// process values - targetPercentList = ArrayList() - for (integer in list) { - targetPercentList.add(1 - integer.toFloat() / max.toFloat()) + // add to animations the values + targetPercentList.add(min(1 - item.y / max, 1f)) } - // Make sure percentList.size() == targetPercentList.size() + // post list if (percentList.isEmpty() || percentList.size < targetPercentList.size) { val temp = targetPercentList.size - percentList.size for (i in 0 until temp) { @@ -136,64 +164,99 @@ class BarChartView @JvmOverloads constructor(context: Context?, attrs: Attribute } } + // Misc operations + + fgPaint = Paint().apply { + isAntiAlias = true + color = yAxis.color + } + removeCallbacks(animator) post(animator) } override fun onDraw(canvas: Canvas) { if (percentList.isNotEmpty()) { - for (i in 1 until percentList.size) { - val left = spacing * i + barWidth * (i - 1) - Log.d(TAG, "$spacing, $i, $barWidth = $left") - val right = (spacing + barWidth) * i - val bottom = height - bottomTextHeight - textTopMargin - val top = topMargin + ((bottom - topMargin) * percentList[i - 1]) + // draw each rectangles + for (i in 1..getDisplayedEntries()) { +// Log.d(TAG, percentList[i - 1].toString()) + val left = spacing * i + barWidth * (i - 1).toFloat() +// Log.d(TAG, "$spacing, $i, $barWidth = $left") + val right = (spacing + barWidth) * i.toFloat() + val bottom = height - bottomTextHeight - textTopMargin.toFloat() + val top = bottom * percentList[this.getXOffset() + i - 1] - rect.set(left, top.toInt(), right, bottom) - - canvas.drawRect(rect, fgPaint) + // create rounded rect + canvas.drawRoundRect(left, top, right, bottom, 8f, 8f, fgPaint) + // remove the bottom corners DUH + canvas.drawRect(left, max(top, bottom - 8f), right, bottom, fgPaint) } } - if (bottomTextList != null && !bottomTextList!!.isEmpty()) { + if (bottomTexts.isNotEmpty() && numberOfLabels > 0) { + val size = bottomTexts.size var i = 1 - for (s in bottomTextList!!) { + var items = size / max(2, (numberOfLabels - 2)) + + // handle cases where size is even and numberOfLabels is 3 + if (size % 2 != 0) { + items += 1 + } + + val rect = Rect() + +// Log.i(TAG, "$size / max($numberOfLabels - 2, 2) = $items") + for (s in bottomTexts) { + + if ((numberOfLabels <= 2 || i % items != 0) && i != 1 && i != size) { + i++ + continue + } + +// Log.i(TAG, "Drawing $i") + + xAxis.labels.build().getTextBounds(s, 0, s.length, rect) + canvas.drawText( s, - (spacing * i + barWidth * (i - 1) + barWidth / 2).toFloat(), + // handle last entry overflowing + min( + // handle first entry overflowing + max( + (spacing * i + barWidth * (i - 1) + barWidth / 2).toFloat(), + rect.width() / 2f + ), + measuredWidth - rect.width() / 2f + ), + (height - bottomTextDescent).toFloat(), - textPaint + xAxis.labels.build() ) i++ + if (numberOfLabels == 1) { + break + } } } } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val mViewWidth = measureWidth(widthMeasureSpec) - val mViewHeight = measureHeight(heightMeasureSpec) - setMeasuredDimension(mViewWidth, mViewHeight) + override fun onChartMoved(movementX: Float, movementY: Float) { + movementOffset = (movementX / 100).toInt() + refresh() } - private fun measureWidth(measureSpec: Int): Int { - var preferred = 0 - if (bottomTextList != null) { - preferred = bottomTextList!!.size * (barWidth + spacing) - } - return getMeasurement(measureSpec, preferred) + override fun onZoomChanged(scale: Float) { + Log.d(TAG, "New Zoom: $scale") + zoom = scale + refresh() } - private fun measureHeight(measureSpec: Int): Int { - val preferred = 222 - return getMeasurement(measureSpec, preferred) + private fun getXOffset(): Int { + return min(max(0, xAxis.baseOffset + movementOffset), list.size - 1 - getDisplayedEntries()) } - private fun getMeasurement(measureSpec: Int, preferred: Int): Int { - val specSize = MeasureSpec.getSize(measureSpec) - val measurement = when (MeasureSpec.getMode(measureSpec)) { - MeasureSpec.EXACTLY -> specSize - MeasureSpec.AT_MOST -> Math.min(preferred, specSize) - else -> preferred - } - return measurement + private fun getDisplayedEntries(): Int { +// Log.d(TAG, "Number of entries displayed ${list.size}, ${xAxis.entriesDisplayed} + (($zoom - 100) * 10) = ${xAxis.entriesDisplayed + ((zoom - 100) * 10).toInt()}") + return max(1, min(list.size, xAxis.entriesDisplayed + ((zoom - 100) * 10).toInt())) } + } diff --git a/charts/src/main/java/com/dzeio/charts/views/BaseChart.kt b/charts/src/main/java/com/dzeio/charts/views/BaseChart.kt new file mode 100644 index 0000000..777cd2c --- /dev/null +++ b/charts/src/main/java/com/dzeio/charts/views/BaseChart.kt @@ -0,0 +1,115 @@ +package com.dzeio.charts.views + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.ScaleGestureDetector +import android.view.View +import androidx.core.view.MotionEventCompat + +abstract class BaseChart @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : + View(context, attrs) { + + // The ‘active pointer’ is the one currently moving our object. + private var activePointerId = INVALID_POINTER_ID + + private var lastTouchX: Float = 0f + private var lastTouchY: Float = 0f + + private var posX: Float = 0f + private var posY: Float = 0f + + private var lastZoom: Float = 100f + private var currentZoom: Float = 0f + + open fun onChartMoved(movementX: Float, movementY: Float) {} + + /** + * @param scale Float starting from 100% + * + * 99-% zoom out, + * 101+% zoom in + */ + open fun onZoomChanged(scale: Float) {} + + private val scaleGestureDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + + if (currentZoom != detector.scaleFactor) { + currentZoom = detector.scaleFactor + onZoomChanged(lastZoom + -currentZoom + 1) + } + + return super.onScale(detector) + } + + override fun onScaleEnd(detector: ScaleGestureDetector?) { + super.onScaleEnd(detector) + + lastZoom += -currentZoom + 1 + } + }) + + /** + * Code mostly stolen from https://developer.android.com/training/gestures/scale#drag + */ + override fun onTouchEvent(ev: MotionEvent): Boolean { + super.onTouchEvent(ev) + + scaleGestureDetector.onTouchEvent(ev) + + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + ev.actionIndex.also { pointerIndex -> + // Remember where we started (for dragging) + lastTouchX = ev.getX(pointerIndex) + lastTouchY = ev.getY(pointerIndex) + } + + // Save the ID of this pointer (for dragging) + activePointerId = ev.getPointerId(0) + } + + MotionEvent.ACTION_MOVE -> { + // Find the index of the active pointer and fetch its position + val (x: Float, y: Float) = + ev.findPointerIndex(activePointerId).let { pointerIndex -> + // Calculate the distance moved + ev.getX(pointerIndex) to + ev.getY(pointerIndex) + } + + posX += x - lastTouchX + posY += y - lastTouchY + + onChartMoved(-posX, posY) + + // Remember this touch position for the next move event + lastTouchX = x + lastTouchY = y + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + activePointerId = INVALID_POINTER_ID + } + MotionEvent.ACTION_POINTER_UP -> { + + ev.actionIndex.also { pointerIndex -> + ev.getPointerId(pointerIndex) + .takeIf { it == activePointerId } + ?.run { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + val newPointerIndex = if (pointerIndex == 0) 1 else 0 + lastTouchX = MotionEventCompat.getX(ev, newPointerIndex) + lastTouchY = MotionEventCompat.getY(ev, newPointerIndex) + activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex) + } + } + } + } + return true + } +} \ No newline at end of file