package it.danieleverducci.lunatracker import android.graphics.Color import android.os.Bundle import android.util.Log import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.github.mikephil.charting.charts.BarChart import com.github.mikephil.charting.data.BarData import com.github.mikephil.charting.data.BarDataSet import com.github.mikephil.charting.data.BarEntry import com.github.mikephil.charting.formatter.ValueFormatter import com.github.mikephil.charting.interfaces.datasets.IBarDataSet import it.danieleverducci.lunatracker.entities.LunaEvent import utils.NumericUtils import java.util.Calendar import java.util.Date import kotlin.math.max import kotlin.math.min import androidx.core.graphics.toColorInt class StatisticsActivity : AppCompatActivity() { lateinit var barChart: BarChart lateinit var noDataTextView: TextView lateinit var graphTypeSpinner: Spinner //lateinit var dataTypeSelection: Spinner lateinit var timeRangeSpinner: Spinner enum class GraphType { BOTTLE_EVENTS, BOTTLE_SUM, BOTTLE_SUM_AVERAGE, SLEEP_SUM, SLEEP_SUM_AVERAGE, SLEEP_EVENTS, SLEEP_PATTERN } enum class RangeType { DAY, WEEK, MONTH } // default selection var graphTypeSelection = GraphType.SLEEP_SUM var timeRangeSelection = RangeType.DAY override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_statistics) val logbookName = intent.getStringExtra("LOOGBOOK_NAME") if (logbookName == null) { finish() return } noDataTextView = findViewById(R.id.no_data) barChart = findViewById(R.id.bar_chart) barChart.setBackgroundColor(Color.WHITE) barChart.description.text = "$logbookName" barChart.setDrawValueAboveBar(false) barChart.axisLeft.setAxisMinimum(0F) barChart.axisLeft.setDrawGridLines(false) barChart.axisLeft.setDrawLabels(false) barChart.axisRight.setDrawGridLines(false) barChart.axisRight.setDrawLabels(false) barChart.xAxis.setDrawGridLines(true) barChart.xAxis.setDrawLabels(true) barChart.xAxis.setDrawAxisLine(false) graphTypeSpinner = findViewById(R.id.type_selection) //dataTypeSelection = findViewById(R.id.data_selection) timeRangeSpinner = findViewById(R.id.time_selection) setupSpinner("SLEEP_SUM", R.id.type_selection, R.array.StatisticsTypeLabels, R.array.StatisticsTypeValues, object : SpinnerItemSelected { override fun call(newValue: String?) { newValue ?: return graphTypeSelection = when (newValue) { "BOTTLE_EVENTS" -> GraphType.BOTTLE_EVENTS "BOTTLE_SUM" -> GraphType.BOTTLE_SUM "BOTTLE_SUM_AVERAGE" -> GraphType.BOTTLE_SUM_AVERAGE "SLEEP_SUM_AVERAGE" -> GraphType.SLEEP_SUM_AVERAGE "SLEEP_SUM" -> GraphType.SLEEP_SUM "SLEEP_SUM_AVERAGE" -> GraphType.SLEEP_SUM_AVERAGE "SLEEP_EVENTS" -> GraphType.SLEEP_EVENTS "SLEEP_PATTERN" -> GraphType.SLEEP_PATTERN else -> { Log.e(TAG, "Invalid graph type selection: $newValue") return } } //Log.d("event", "new value: $newValue") updateGraph() } } ) /* setupSpinner(dataTypeSelectionValue, R.id.data_selection, R.array.StatisticsDataLabels, R.array.StatisticsDataValues, object : SpinnerItemSelected { override fun call(newValue: String?) { if (newValue != null) { dataTypeSelectionValue = newValue //Log.d("event", "new value: $newValue") updateGraph() } } } ) */ setupSpinner("DAY", R.id.time_selection, R.array.StatisticsTimeLabels, R.array.StatisticsTimeValues, object : SpinnerItemSelected { override fun call(newValue: String?) { newValue ?: return timeRangeSelection = when (newValue) { "DAY" -> RangeType.DAY "WEEK" -> RangeType.WEEK "MONTH" -> RangeType.MONTH else -> { Log.e(TAG, "Invalid time range selection: $newValue") return } } //Log.d("event", "new value: $newValue") updateGraph() } } ) updateGraph() } fun showSleepBarGraph(events: List, unixToSpan: (Long) -> Int, spanToUnix: (Int) -> Long) { fun getEndTime(event: LunaEvent): Long { if (event.quantity == 0) { // sleep is still ongoing val dateTime = Calendar.getInstance() return (dateTime.time.time / 1000) - event.time } else { return event.quantity.toLong() } } data class SleepRange(val start: Long, val end: Long) val ranges = arrayListOf() // Transform events into time ranges. // Merge overlapping times and extend // ongoing sleep events until now. val dateTime = Calendar.getInstance() for (event in events) { val endTime = if (event.quantity == 0) { dateTime.time.time / 1000 // now } else { event.time + event.quantity } /* // TODO: handle overlap if (ranges.isNotEmpty()) { val lastItem = ranges.lastItem() if (lastItem.start) if (lastItem.end <= event.time) { // distinct } } } */ ranges.add(SleepRange(event.time, endTime)) } // unix time span of all events val startUnix = events.minOf { it.time } val endUnix = events.maxOf { getEndTime(it) } // convert to days, weeks or months val startSpan = unixToSpan(startUnix) val endSpan = unixToSpan(endUnix) val values = ArrayList() fun countEvent(index: Int) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), 0F)) } // update value values[index].y += 1F } fun accumulateValue(index: Int, value: Long) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), 0F)) } // update value values[index].y += value.toFloat() } fun stackValue(index: Int, value: Long) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), FloatArray(0))) } val x = values[index].x val yVals = values[index].yVals // update value val newYVals = appendToFloatArray(yVals, value.toFloat()) values[index] = BarEntry(x, newYVals) } // awake/sleep fun stackValuePattern(index: Int, spanBegin: Long, spanEnd: Long, begin: Long, end: Long) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), FloatArray(0))) } assert(begin in spanBegin..spanEnd) assert(end in spanBegin..spanEnd) assert(begin <= end) val x = values[index].x val yVals = values[index].yVals // alternating sleep/awake durations val y = yVals.fold(0F) { acc, next -> acc + next } // y value is seconds when last awake val awakeDuration = max(begin - spanBegin - y.toLong(), 0L) val sleepDuration = end - begin if ((awakeDuration + sleepDuration) > (spanEnd - spanBegin)) { Log.e(TAG, "Invalid sleep duration, exceeds day/week or month bounds => ignore value") return } // update value val newYVals = appendToFloatArray(yVals, awakeDuration.toFloat(), sleepDuration.toFloat()) values[index] = BarEntry(x, newYVals) } /* fun addStack24hCap(spanDuration: Long) { // spanDuration is usually a day, week, month in seconds Log.d(TAG, "spanDuration: $spanDuration, ${24*60*60}") for (i in values.indices) { val x = values[i].x val yVals = values[i].yVals val y = yVals.fold(0F) { acc, next -> acc + next } val cap = spanDuration.toFloat() - y if (cap >= 0F) { // Add a cap value and an 0 value to keep the number of spans even. // This is important, since we configure two colors and they will alternate. val newYVals = appendToFloatArray(yVals, cap, 0F) values[i] = BarEntry(x, newYVals) } else { Log.e(TAG, "Invalid remaining sleep duration, exceeds day/week or month bounds => ignore") } } } */ for (range in ranges) { // a sleep event can span to another day // distribute sleep time over the days val startUnix = range.start //event.time val endUnix = range.end //getEndTime(event) val begIndex = unixToSpan(startUnix) val endIndex = unixToSpan(endUnix) var mid = startUnix //Log.d(TAG, "startUnix: ${Date(startUnix * 1000)}, endUnix: ${Date(endUnix * 1000)}, begIndex: $begIndex, endIndex: $endIndex (index diff: ${endIndex - begIndex})") for (i in begIndex..endIndex) { val spanBegin = spanToUnix(i) val spanEnd = spanToUnix(i + 1) //Log.d(TAG, "mid: ${Date(mid * 1000)}, spanBegin: ${Date(spanBegin * 1000)}, spanEnd: ${Date(spanEnd * 1000)}, endUnix: ${Date(endUnix * 1000)}") val sleepBegin = max(mid, spanBegin) val sleepEnd = min(endUnix, spanEnd) val index = i - startSpan val duration = sleepEnd - sleepBegin //Log.d(TAG, "[$index] sleepBegin: ${Date(sleepBegin * 1000)}, sleepEnd: ${Date(sleepEnd * 1000)}, ${formatTimeDuration(this, duration)}") if (graphTypeSelection == GraphType.SLEEP_PATTERN) { stackValuePattern(index, spanBegin, spanEnd, sleepBegin, sleepEnd) } else if (graphTypeSelection == GraphType.SLEEP_SUM) { stackValue(index, duration) } else if (graphTypeSelection == GraphType.SLEEP_SUM_AVERAGE) { accumulateValue(index, duration) } else if (graphTypeSelection == GraphType.SLEEP_EVENTS) { countEvent(index) } else { Log.e(TAG, "Unexpected graph type.") return } mid = sleepEnd } // TODO: move addStack24h here, since the spans can have different length (edge case and does not really matter) } //addStack24hCap(spanToUnix(1) - spanToUnix(0)) // for debugging for (value in values) { val y = value.yVals.fold(0F) { acc, next -> acc + next } val yVals = value.yVals.joinToString { it.toString() } Log.d(TAG, "value: ${value.x} $y ($yVals)") } // list of dates val labels = ArrayList() for (index in values.indices) { labels.add(spanToLabel(spanToUnix(startSpan + index))) } val set1 = BarDataSet(values, "") if (graphTypeSelection == GraphType.SLEEP_PATTERN) { // awake phase color is transparent set1.colors = arrayListOf("#00000000".toColorInt(), ColorTemplate.rgb("#72d7f5")) set1.setDrawValues(false) // too many values => let's disable it } else { set1.setDrawValues(false) } set1.setDrawIcons(false) barChart.legend.isEnabled = false barChart.xAxis.setLabelCount(min(values.size, 24)) val dataSets = ArrayList() dataSets.add(set1) val data = BarData(dataSets) data.setValueFormatter(object : ValueFormatter() { override fun getFormattedValue(value: Float): String { //Log.d(TAG, "getFormattedValue ${dataTypeSelectionValue} ${eventTypeSelectionValue}") return NumericUtils(applicationContext).formatEventQuantity(LunaEvent.TYPE_SLEEP, value.toInt()) } }) barChart.xAxis.valueFormatter = object: ValueFormatter() { override fun getFormattedValue(value: Float): String { return labels.getOrElse(value.toInt(), {"?"}) } } //data.setValueTextSize(12f) barChart.setData(data) barChart.invalidate() } fun showBottleBarGraph(events: List, unixToSpan: (Long) -> Int, spanToUnix: (Int) -> Long) { // unix time span of all events val startUnix = events.minOf { it.time } val endUnix = events.maxOf { it.time } // convert to days, weeks or months val startSpan = unixToSpan(startUnix) val endSpan = unixToSpan(endUnix) val values = ArrayList() fun countValue(index: Int) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), 0F)) } // update value values[index].y += 1F } fun accumulateValue(index: Int, duration: Long) { // create initial values while (index >= values.size) { values.add(BarEntry(values.size.toFloat(), 0F)) } // update value values[index].y += duration.toFloat() } for (event in events) { if (graphTypeSelection == GraphType.BOTTLE_EVENTS) { val index = unixToSpan(event.time) - startSpan countValue(index) } else if (graphTypeSelection == GraphType.BOTTLE_SUM) { val index = unixToSpan(event.time) - startSpan //Log.d(TAG, "[${index}] ${event.quantity}") accumulateValue(index, event.quantity.toLong()) } else { Log.e(TAG, "unhandled graphTypeSelection") return } } // list of dates val labels = ArrayList() for (index in values.indices) { labels.add(spanToLabel(spanToUnix(startSpan + index))) } val set1 = BarDataSet(values, "") set1.setDrawValues(true) set1.setDrawIcons(false) //showGraph(set1, valueLabels) // for debugging //val sum1 = allEvents.fold(0) { acc, event -> acc + event.quantity } //val sum2 = values.fold(0F) { acc, item -> acc + item.y } //Log.d(TAG, "sum1: $sum1, sum2: $sum2") barChart.xAxis.setLabelCount(min(values.size, 24)) val dataSets = ArrayList() dataSets.add(set1) val data = BarData(dataSets) data.setValueFormatter(object : ValueFormatter() { override fun getFormattedValue(value: Float): String { //Log.d(TAG, "getFormattedValue ${dataTypeSelectionValue} ${eventTypeSelectionValue}") return when (graphTypeSelection) { GraphType.BOTTLE_EVENTS -> value.toInt().toString() GraphType.BOTTLE_SUM -> NumericUtils(applicationContext).formatEventQuantity(LunaEvent.TYPE_BABY_BOTTLE, value.toInt()) else -> { Log.e(TAG, "unhandled graphTypeSelection") value.toInt().toString() } } } }) barChart.xAxis.valueFormatter = object: ValueFormatter() { override fun getFormattedValue(value: Float): String { return labels.getOrElse(value.toInt(), {"?"}) } } data.setValueTextSize(12f) barChart.setData(data) barChart.invalidate() } fun spanToLabel(unixSeconds: Long): String { val dateTime = Calendar.getInstance() dateTime.time = Date(1000L * unixSeconds) val year = dateTime.get(Calendar.YEAR) val month = dateTime.get(Calendar.MONTH) + 1 // month starts at 0 val week = dateTime.get(Calendar.WEEK_OF_YEAR) val day = dateTime.get(Calendar.DAY_OF_MONTH) return when (timeRangeSelection) { RangeType.DAY -> "$day/$month/$year" RangeType.WEEK -> "$week/$year" RangeType.MONTH -> "$month/$year" } } fun updateGraph() { val unixToSpan = when (timeRangeSelection) { RangeType.DAY -> { unix: Long -> unixToDays(unix) } RangeType.WEEK -> { unix: Long -> unixToWeeks(unix) } RangeType.MONTH -> { unix: Long -> unixToMonths(unix) } } val spanToUnix = when (timeRangeSelection) { RangeType.DAY -> { span: Int -> daysToUnix(span) } RangeType.WEEK -> { span: Int -> weeksToUnix(span) } RangeType.MONTH -> { span: Int -> monthsToUnix(span) } } val eventType = when (graphTypeSelection) { GraphType.BOTTLE_EVENTS, GraphType.BOTTLE_SUM, GraphType.BOTTLE_SUM_AVERAGE -> LunaEvent.TYPE_BABY_BOTTLE GraphType.SLEEP_SUM, GraphType.SLEEP_SUM_AVERAGE, GraphType.SLEEP_EVENTS, GraphType.SLEEP_PATTERN -> LunaEvent.TYPE_SLEEP } val events = MainActivity.allEvents.filter { it.type == eventType }.sortedBy { it.time } if (events.isEmpty()) { barChart.visibility = View.GONE noDataTextView.visibility = View.VISIBLE } else { barChart.visibility = View.VISIBLE noDataTextView.visibility = View.GONE when (graphTypeSelection) { GraphType.BOTTLE_EVENTS, GraphType.BOTTLE_SUM, GraphType.BOTTLE_SUM_AVERAGE -> showBottleBarGraph(events, unixToSpan, spanToUnix) GraphType.SLEEP_SUM, GraphType.SLEEP_SUM_AVERAGE, GraphType.SLEEP_EVENTS, GraphType.SLEEP_PATTERN -> showSleepBarGraph(events, unixToSpan, spanToUnix) } } } private interface SpinnerItemSelected { fun call(newValue: String?) } private fun setupSpinner( currentValue: String, spinnerId: Int, arrayId: Int, arrayValuesId: Int, callback: SpinnerItemSelected ) { val arrayValues = resources.getStringArray(arrayValuesId) val spinner = findViewById(spinnerId) val spinnerAdapter = ArrayAdapter.createFromResource(this, arrayId, R.layout.statistics_spinner_item) spinner.adapter = spinnerAdapter spinner.setSelection(arrayValues.indexOf(currentValue)) spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { var check = 0 override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) { if (pos >= arrayValues.size) { Toast.makeText( this@StatisticsActivity, "pos out of bounds: $arrayValues", Toast.LENGTH_SHORT ).show() return } if (check++ > 0) { callback.call(arrayValues[pos]) } } override fun onNothingSelected(parent: AdapterView<*>?) { // ignore } } } companion object { const val TAG = "StatisticsActivity" fun unixToMonths(seconds: Long): Int { val dateTime = Calendar.getInstance() dateTime.time = Date(seconds * 1000) val years = dateTime.get(Calendar.YEAR) val months = dateTime.get(Calendar.MONTH) return 12 * years + months } fun monthsToUnix(months: Int): Long { val dateTime = Calendar.getInstance() dateTime.time = Date(0) dateTime.set(Calendar.YEAR, months / 12) dateTime.set(Calendar.MONTH, months % 12) dateTime.set(Calendar.HOUR, 0) dateTime.set(Calendar.MINUTE, 0) dateTime.set(Calendar.SECOND, 0) return dateTime.time.time / 1000 } fun unixToWeeks(seconds: Long): Int { val dateTime = Calendar.getInstance() dateTime.time = Date(seconds * 1000) val years = dateTime.get(Calendar.YEAR) val weeks = dateTime.get(Calendar.WEEK_OF_YEAR) return 52 * years + weeks } fun weeksToUnix(weeks: Int): Long { val dateTime = Calendar.getInstance() dateTime.time = Date(0) dateTime.set(Calendar.YEAR, weeks / 52) dateTime.set(Calendar.WEEK_OF_YEAR, weeks % 52) dateTime.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) dateTime.set(Calendar.HOUR, 0) dateTime.set(Calendar.MINUTE, 0) dateTime.set(Calendar.SECOND, 0) return dateTime.time.time / 1000 } fun unixToDays(seconds: Long): Int { val dateTime = Calendar.getInstance() dateTime.time = Date(seconds * 1000) val years = dateTime.get(Calendar.YEAR) val days = dateTime.get(Calendar.DAY_OF_YEAR) return 365 * years + days } // convert from days to Date fun daysToUnix(days: Int): Long { val dateTime = Calendar.getInstance() dateTime.time = Date(0) dateTime.set(Calendar.YEAR, days / 365) dateTime.set(Calendar.DAY_OF_YEAR, days % 365) dateTime.set(Calendar.HOUR, 0) dateTime.set(Calendar.MINUTE, 0) dateTime.set(Calendar.SECOND, 0) return dateTime.time.time / 1000 } fun appendToFloatArray(array: FloatArray, vararg values: Float): FloatArray { // create new array val newArray = FloatArray(array.size + values.size) // copy old values for (i in array.indices) { newArray[i] = array[i] } // add new values for (i in values.indices) { newArray[array.size + i] = values[i] } return newArray } } }