From 5882b7a874d838e3b6d646f98ad3d0eec2cfdf13 Mon Sep 17 00:00:00 2001 From: Moritz Warning Date: Thu, 6 Nov 2025 21:41:36 +0100 Subject: [PATCH] statistics: add LongTermStatisticsActivity: add statistics and sleep events --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 4 + .../lunatracker/LongTermStatisticsActivity.kt | 901 ++++++++++++++++++ .../lunatracker/MainActivity.kt | 28 +- app/src/main/java/utils/NumericUtils.kt | 23 +- .../main/res/layout/activity_statistics.xml | 50 + .../main/res/layout/dialog_event_details.xml | 1 - app/src/main/res/layout/more_events_popup.xml | 11 + .../res/layout/statistics_spinner_item.xml | 8 + app/src/main/res/values/arrays.xml | 28 + app/src/main/res/values/strings.xml | 10 + gradle/libs.versions.toml | 4 + settings.gradle.kts | 2 +- 13 files changed, 1049 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/LongTermStatisticsActivity.kt create mode 100644 app/src/main/res/layout/activity_statistics.xml create mode 100644 app/src/main/res/layout/statistics_spinner_item.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06e1f6a..35223d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,4 +60,5 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + implementation(libs.mpandroidchart.vv310) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97f621b..a397fd3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ android:name=".SettingsActivity" android:label="@string/settings_title" android:theme="@style/Theme.LunaTracker"/> + \ No newline at end of file diff --git a/app/src/main/java/it/danieleverducci/lunatracker/LongTermStatisticsActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/LongTermStatisticsActivity.kt new file mode 100644 index 0000000..9227c49 --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/LongTermStatisticsActivity.kt @@ -0,0 +1,901 @@ +package it.danieleverducci.lunatracker + +import android.graphics.Canvas +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 androidx.core.graphics.toColorInt +import com.github.mikephil.charting.animation.ChartAnimator +import com.github.mikephil.charting.charts.BarChart +import com.github.mikephil.charting.components.YAxis +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.data.Entry +import com.github.mikephil.charting.formatter.ValueFormatter +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.interfaces.dataprovider.BarDataProvider +import com.github.mikephil.charting.listener.OnChartValueSelectedListener +import com.github.mikephil.charting.renderer.HorizontalBarChartRenderer +import com.github.mikephil.charting.utils.ViewPortHandler +import it.danieleverducci.lunatracker.entities.LunaEvent +import utils.DateUtils +import utils.NumericUtils +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +class LongTermStatisticsActivity : AppCompatActivity() { + var lastToastShown = 0L + + lateinit var barChart: BarChart + lateinit var noDataTextView: TextView + lateinit var graphTypeSpinner: Spinner + lateinit var timeRangeSpinner: Spinner + + lateinit var unixToSpan: (Long) -> Int + lateinit var spanToUnix: (Int) -> Long + + enum class GraphType { + BOTTLE_EVENTS, + BOTTLE_SUM, + SLEEP_SUM, + SLEEP_EVENTS, + SLEEP_PATTERN + } + + enum class TimeRange { + DAY, + WEEK, + MONTH + } + + 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.setDrawLabels(true) + barChart.xAxis.setDrawAxisLine(false) + + barChart.isScaleXEnabled = false + barChart.isScaleYEnabled = true + + graphTypeSpinner = findViewById(R.id.graph_type_selection) + timeRangeSpinner = findViewById(R.id.time_range_selection) + + setupSpinner(graphTypeSelection.name, + R.id.graph_type_selection, + R.array.StatisticsTypeLabels, + R.array.StatisticsTypeValues, + object : SpinnerItemSelected { + override fun call(newValue: String?) { + //Log.d("event", "new value: $newValue") + newValue ?: return + graphTypeSelection = GraphType.valueOf(newValue) + showGraph() + } + } + ) + + setupSpinner(timeRangeSelection.name, + R.id.time_range_selection, + R.array.StatisticsTimeLabels, + R.array.StatisticsTimeValues, + object : SpinnerItemSelected { + override fun call(newValue: String?) { + //Log.d("event", "new value: $newValue") + newValue ?: return + timeRangeSelection = TimeRange.valueOf(newValue) + setSpans() + showGraph() + } + } + ) + + setSpans() + showGraph() + } + + fun setSpans() { + unixToSpan = when (timeRangeSelection) { + TimeRange.DAY -> { unix: Long -> unixToDays(unix) } + TimeRange.WEEK -> { unix: Long -> unixToWeeks(unix) } + TimeRange.MONTH -> { unix: Long -> unixToMonths(unix) } + } + + spanToUnix = when (timeRangeSelection) { + TimeRange.DAY -> { span: Int -> daysToUnix(span) } + TimeRange.WEEK -> { span: Int -> weeksToUnix(span) } + TimeRange.MONTH -> { span: Int -> monthsToUnix(span) } + } + } + + data class SleepRange(val start: Long, var end: Long) + + fun toSleepRanges(events: List): ArrayList { + val ranges = arrayListOf() + val now = System.currentTimeMillis() / 1000 + + // Transform events into time ranges. + // Merge overlapping times and extend + // ongoing sleep events until now. + var warningShown = false + for (event in events) { + val startTime = event.time + val endTime = if (event.quantity == 0) { + now + } else { + event.time + event.quantity + } + + // handle overlap + val previousRange = ranges.lastOrNull() + if (previousRange != null && previousRange.end > startTime) { + // cap previous range to avoid overlap + previousRange.end = startTime + if (!warningShown) { + Toast.makeText(applicationContext, "Overlapping sleep event at ${DateUtils.formatDateTime(startTime)}", Toast.LENGTH_SHORT).show() + warningShown = true + } + } + + ranges.add(SleepRange(startTime, endTime)) + } + + return ranges + } + + fun showSleepPatternBarGraphSlotted(state: GraphState) { + val ranges = toSleepRanges(state.events) + val values = ArrayList() + val stack = ArrayList(List(state.endSpan - state.startSpan + 1) { IntArray(24 * 60 * 60 / SLEEP_PATTERN_GRANULARITY) }) + + fun stackValuePattern(index: Int, spanBegin: Long, spanEnd: Long, begin: Long, end: Long) { + val beginDays = unixToDays(begin) + val endDays = unixToDays(end) + var mid = begin + + for (i in beginDays..endDays) { + // i is the days/weeks/months since unix epoch + val dayBegin = daysToUnix(i) + val dayEnd = daysToUnix(i + 1) + val sleepBegin = max(mid, dayBegin) + val sleepEnd = min(end, dayEnd) + + if (sleepBegin != sleepEnd) { + assert(dayBegin <= dayEnd) + assert(sleepBegin <= sleepEnd) + val iBegin = (sleepBegin - dayBegin) / SLEEP_PATTERN_GRANULARITY + val iEnd = iBegin + (sleepEnd - sleepBegin) / SLEEP_PATTERN_GRANULARITY + for (j in iBegin.. 0) + assert(occurrences <= maxOccurrences) + + // map to color + val q = occurrences.toFloat() / maxOccurrences.toFloat() + val i = q * (SLEEP_PATTERN_COLORS.size - 1).toFloat() + return SLEEP_PATTERN_COLORS[i.toInt()] + } + + val allColors = ArrayList() + + // convert array of time slots that represent a day to value and color arrays used by chart library + for ((index, dayArray) in stack.withIndex()) { + val daysWithData = state.dayCounter.countDaysWithData(spanToUnix(state.startSpan + index), spanToUnix(state.startSpan + index + 1)) + + val vals = ArrayList() + + var prevIndex = -1 // time slot index + var prevValue = -1 // number of entries we have found for time slot + for ((i, v) in dayArray.withIndex()) { + if (i == 0) { + prevIndex = i + prevValue = v + } else if (prevValue != v) { + vals.add((i - prevIndex).toFloat()) + allColors.add(mapColor(prevValue.coerceAtMost(daysWithData), daysWithData)) + prevIndex = i + prevValue = v + } + } + + if (prevIndex != -1) { + vals.add((dayArray.size - prevIndex).toFloat()) + allColors.add(mapColor(prevValue, daysWithData)) + } + + assert(values.size == index) + values.add(BarEntry(values.size.toFloat(), vals.toFloatArray())) + } + + barChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(e: Entry?, h: Highlight?) { + if (e == null || h == null) { + return + } + + val index = e.x.toInt() + if (index !in 0..values.size) { + return + } + + val value = values[index] + if (value.yVals == null || h.stackIndex !in 0..value.yVals.size) { + return + } + + if ((lastToastShown + TOAST_FREQUENCY_MS) > System.currentTimeMillis()) { + // only show one Toast message after another + return + } + + val dayStartUnix = daysToUnix(unixToDays(state.startUnix) + index) + + val startSeconds = + SLEEP_PATTERN_GRANULARITY * value.yVals.sliceArray(0.. acc + y.toInt() } + val durationSeconds = + SLEEP_PATTERN_GRANULARITY * value.yVals[h.stackIndex].toInt() + val endSeconds = startSeconds + durationSeconds + + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + val startTimeString = + format.format((dayStartUnix + startSeconds) * 1000).toString() + val endTimeString = + format.format((dayStartUnix + endSeconds) * 1000).toString() + val durationString = DateUtils.formatTimeDuration(applicationContext, durationSeconds.toLong()) + + val daysWithData = + stack[e.x.toInt()][startSeconds / SLEEP_PATTERN_GRANULARITY] + val daysWithDataMax = state.dayCounter.countDaysWithData( + spanToUnix(state.startSpan + index), + spanToUnix(state.startSpan + index + 1) + ) + + // percentage of days in this span where baby is asleep in this time slot + val pc = if (daysWithDataMax > 0) { + (100F * daysWithData.toFloat() / daysWithDataMax.toFloat()).toInt() + } else { + // no data for this day + 0 + } + + Toast.makeText( + applicationContext, + "$startTimeString - $endTimeString ($durationString) - ${pc}%", + Toast.LENGTH_LONG + ).show() + lastToastShown = System.currentTimeMillis() + } + + override fun onNothingSelected() {} + }) + + val set1 = BarDataSet(values, "") + set1.colors = allColors + set1.setDrawValues(false) // usually too many values + set1.isHighlightEnabled = true + set1.setDrawIcons(false) + + val data = BarData(set1) + data.setValueTextSize(12f) + + val valueCount = min(values.size, 24) + barChart.setData(data) + barChart.legend.isEnabled = false + barChart.setVisibleXRangeMaximum(valueCount.toFloat()) + barChart.xAxis.setLabelCount(valueCount) + barChart.xAxis.setCenterAxisLabels(false) + barChart.moveViewTo(data.getEntryCount().toFloat(), 0f, YAxis.AxisDependency.LEFT) + barChart.invalidate() + } + + // Sleep pattern bars that do not use time slots. + // This is useful/nicer for bars that only represent data of a singular days. + fun showSleepPatternBarGraphDaily(state: GraphState) { + val ranges = toSleepRanges(state.events) + val values = ArrayList(List(state.endSpan - state.startSpan + 1) { BarEntry(it.toFloat(), FloatArray(0)) }) + + // stack awake/sleep durations + fun stackValuePattern(index: Int, spanBegin: Long, spanEnd: Long, begin: Long, end: Long) { + 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()) + assert(index == x.toInt()) + values[index] = BarEntry(index.toFloat(), newYVals) + } + + for (range in ranges) { + // a sleep event can span to another day + // distribute sleep time over the days + val startUnix = range.start + val endUnix = range.end + val begIndex = unixToSpan(startUnix) + val endIndex = unixToSpan(endUnix) + var mid = startUnix + + for (i in begIndex..endIndex) { + // i is the days/weeks/months since unix epoch + val spanBegin = spanToUnix(i) + val spanEnd = spanToUnix(i + 1) + val sleepBegin = max(mid, spanBegin) + val sleepEnd = min(endUnix, spanEnd) + val index = i - state.startSpan + val duration = sleepEnd - sleepBegin + + state.dayCounter.setDaysWithData(sleepBegin, sleepEnd) + stackValuePattern(index, spanBegin, spanEnd, sleepBegin, sleepEnd) + + mid = sleepEnd + } + } + + // awake phase color is transparent + val set1 = BarDataSet(values, "") + set1.colors = arrayListOf("#00000000".toColorInt(), "#72d7f5".toColorInt()) + set1.setDrawValues(false) // usually too many values + set1.setDrawIcons(false) + set1.isHighlightEnabled = true + + barChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(e: Entry?, h: Highlight?) { + if (e == null || h == null) { + return + } + + val index = e.x.toInt() + if (index !in 0..values.size) { + return + } + + val value = values[index] + if (value.yVals == null || h.stackIndex !in 0..value.yVals.size) { + return + } + + if ((lastToastShown + TOAST_FREQUENCY_MS) > System.currentTimeMillis()) { + // only show one Toast message after another + return + } + + val duration = value.yVals[h.stackIndex].toInt() + val durationString = DateUtils.formatTimeDuration(applicationContext, duration.toLong()) + + val offsetUnix = spanToUnix(state.startSpan + e.x.toInt()) // start of the time span (day/week/month) + val startUnix = offsetUnix + value.yVals.sliceArray(0.. acc + y.toInt() } + val endUnix = startUnix + duration + + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + val startTimeString = format.format(startUnix * 1000).toString() + val endTimeString = format.format(endUnix * 1000).toString() + Toast.makeText(applicationContext, "$startTimeString - $endTimeString ($durationString)", Toast.LENGTH_LONG).show() + lastToastShown = System.currentTimeMillis() + } + + override fun onNothingSelected() {} + }) + + val data = BarData(set1) + data.setValueTextSize(12f) + + val valueCount = min(values.size, 24) + barChart.setData(data) + barChart.legend.isEnabled = false + barChart.setVisibleXRangeMaximum(valueCount.toFloat()) + barChart.xAxis.setLabelCount(valueCount) + barChart.xAxis.setCenterAxisLabels(false) + barChart.moveViewTo(data.getEntryCount().toFloat(), 0f, YAxis.AxisDependency.LEFT) + barChart.invalidate() + } + + // Make sure the value on the bar is not out of screen. + class CustomHorizontalBarChartRenderer(chart: BarDataProvider, animator: ChartAnimator, viewPortHandler: ViewPortHandler): HorizontalBarChartRenderer(chart, animator, viewPortHandler) { + override fun drawValue( + c: Canvas, + valueText: String, + x: Float, + y: Float, + color: Int + ) { + mValuePaint.setColor(color) + c.drawText(valueText, x.coerceAtLeast(60F), y, mValuePaint) + } + } + + fun showSleepBarGraph(state: GraphState) { + val ranges = toSleepRanges(state.events) + val values = ArrayList(List(state.endSpan - state.startSpan + 1) { BarEntry(it.toFloat(), 0F) }) + + for (range in ranges) { + // a sleep event can span to another day + // distribute sleep time over the days + val startUnix = range.start + val endUnix = range.end + val begIndex = unixToSpan(startUnix) + val endIndex = unixToSpan(endUnix) + var mid = startUnix + + for (i in begIndex..endIndex) { + // i is the days/weeks/months since unix epoch + val spanBegin = spanToUnix(i) + val spanEnd = spanToUnix(i + 1) + val sleepBegin = max(mid, spanBegin) + val sleepEnd = min(endUnix, spanEnd) + val index = i - state.startSpan + val duration = sleepEnd - sleepBegin + + state.dayCounter.setDaysWithData(sleepBegin, sleepEnd) + + if (graphTypeSelection == GraphType.SLEEP_SUM) { + values[index].y += duration.toFloat() + } else if (graphTypeSelection == GraphType.SLEEP_EVENTS) { + values[index].y += 1F + } else { + Log.e(TAG, "Unexpected graph type.") + return + } + + mid = sleepEnd + } + } + + for (index in values.indices) { + val daysWithData = state.dayCounter.countDaysWithData(spanToUnix(state.startSpan + index), spanToUnix(state.startSpan + index + 1)) + if (daysWithData == 0) { + assert(values[index].y == 0F) + } else { + values[index].y /= daysWithData + } + } + + val set1 = BarDataSet(values, "") + set1.setDrawValues(true) + set1.setDrawIcons(false) + set1.isHighlightEnabled = false + + val data = BarData(set1) + data.setValueTextSize(12f) + data.setValueFormatter(object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + + val prefix = if (timeRangeSelection == TimeRange.DAY) { "" } else { "⌀ " } + return when (graphTypeSelection) { + GraphType.SLEEP_EVENTS -> { + prefix + value.toInt().toString() + } + GraphType.SLEEP_SUM -> { + prefix + DateUtils.formatTimeDuration(applicationContext, value.toLong()) + } + else -> { + Log.e(TAG, "unhandled graphTypeSelection $graphTypeSelection") + prefix + value.toInt().toString() + } + } + } + }) + + val valueCount = min(values.size, 24) + barChart.renderer = CustomHorizontalBarChartRenderer(barChart, barChart.animator, barChart.viewPortHandler) + barChart.setData(data) + barChart.legend.isEnabled = false + barChart.setVisibleXRangeMaximum(valueCount.toFloat()) + barChart.xAxis.setLabelCount(valueCount) + barChart.xAxis.setCenterAxisLabels(false) + barChart.moveViewTo(data.getEntryCount().toFloat(), 0f, YAxis.AxisDependency.LEFT) + barChart.invalidate() + } + + fun showBottleBarGraph(state: GraphState) { + val values = ArrayList(List(state.endSpan - state.startSpan + 1) { BarEntry(it.toFloat(), 0F) }) + + for (event in state.events) { + val index = unixToSpan(event.time) - state.startSpan + + state.dayCounter.setDaysWithData(event.time, event.time) + + if (graphTypeSelection == GraphType.BOTTLE_EVENTS) { + values[index].y += 1F + } else if (graphTypeSelection == GraphType.BOTTLE_SUM) { + values[index].y += event.quantity.toFloat() + } else { + Log.e(TAG, "unhandled graphTypeSelection: $graphTypeSelection") + return + } + } + + for (index in values.indices) { + val daysWithData = state.dayCounter.countDaysWithData(spanToUnix(state.startSpan + index), spanToUnix(state.startSpan + index + 1)) + if (daysWithData == 0) { + assert(values[index].y == 0F) + } else { + values[index].y /= daysWithData + } + } + + val set1 = BarDataSet(values, "") + set1.setDrawValues(true) + set1.isHighlightEnabled = false + + val data = BarData(set1) + data.setValueTextSize(12f) + data.setValueFormatter(object : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + val prefix = if (timeRangeSelection == TimeRange.DAY) { "" } else { "⌀ " } + return when (graphTypeSelection) { + GraphType.BOTTLE_EVENTS -> { + prefix + value.toInt().toString() + } + GraphType.BOTTLE_SUM -> { + prefix + NumericUtils(applicationContext).formatEventQuantity(LunaEvent.Type.BABY_BOTTLE, value.toInt()) + } + else -> { + Log.e(TAG, "unhandled graphTypeSelection") + prefix + value.toInt() + } + } + } + }) + + val valueCount = min(values.size, 24) + barChart.renderer = CustomHorizontalBarChartRenderer(barChart, barChart.animator, barChart.viewPortHandler) + barChart.setData(data) + barChart.setVisibleXRangeMaximum(valueCount.toFloat()) + barChart.xAxis.setLabelCount(valueCount) + barChart.xAxis.setCenterAxisLabels(false) + barChart.moveViewTo(data.getEntryCount().toFloat(), 0f, YAxis.AxisDependency.LEFT) + barChart.invalidate() + } + + class DayCounter(val startDays: Int, val stopDays: Int) { + val daysWithData = BooleanArray(stopDays - startDays + 1) + + // count days in a span that have data + // e.g. return 7 (days) for applied span of a week where there is data for every day + fun countDaysWithData(beginUnix: Long, endUnix: Long): Int { + val beginDays = unixToDays(beginUnix) + val endDays = unixToDays(endUnix) + var count = 0 + for (i in (beginDays - startDays)..<(endDays - startDays)) { + count += if (daysWithData[i]) { 1 } else { 0 } + } + return count + } + + fun setDaysWithData(beginUnix: Long, endUnix: Long) { + val beginDays = unixToDays(beginUnix) + val endDays = unixToDays(endUnix) + assert(beginDays <= endDays) + assert(startDays <= beginDays) + for (i in (beginDays - startDays)..(endDays - startDays)) { + daysWithData[i] = true + } + } + } + + data class GraphState(val events: List, val dayCounter: DayCounter, val startUnix: Long, val endUnix: Long, val startSpan: Int, val endSpan: Int) + + fun showGraph() { + barChart.fitScreen() + barChart.data?.clearValues() + barChart.xAxis.valueFormatter = null + barChart.notifyDataSetChanged() + barChart.clear() + barChart.invalidate() + + val type = when (graphTypeSelection) { + GraphType.BOTTLE_EVENTS, + GraphType.BOTTLE_SUM -> LunaEvent.Type.BABY_BOTTLE + GraphType.SLEEP_EVENTS, + GraphType.SLEEP_SUM, + GraphType.SLEEP_PATTERN -> LunaEvent.Type.SLEEP + } + + val events = MainActivity.allEvents.filter { it.type == type }.sortedBy { it.time } + + if (events.isEmpty()) { + barChart.visibility = View.GONE + noDataTextView.visibility = View.VISIBLE + return + } else { + barChart.visibility = View.VISIBLE + noDataTextView.visibility = View.GONE + } + + // unix time span of all events + val startUnix = events.minOf { it.time } + val endUnix = System.currentTimeMillis() / 1000 + + // convert to days, weeks or months + val startSpan = unixToSpan(startUnix) + val endSpan = unixToSpan(endUnix) + + // days when the a day/week/month starts/ends + val startDays = unixToDays(spanToUnix(startSpan)) + val endDays = unixToDays(spanToUnix(endSpan + 1)) // until end of next span + val dayCounter = DayCounter(startDays, endDays) + + // print dates + barChart.xAxis.valueFormatter = object: ValueFormatter() { + override fun getFormattedValue(value: Float): String { + val index = value.toInt() + val unixSeconds = spanToUnix(startSpan + index) + + 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) + + // Adjust years if the first week of a year starts in the previous year. + val years = if (month == 12 && week == 1) { + year + 1 + } else { + year + } + + val days = "%02d".format(day) + val weeks = "%02d".format(week) + val months = "%02d".format(month) + + return when (timeRangeSelection) { + TimeRange.DAY -> "$days/$months/$years" + TimeRange.WEEK -> "$weeks/$years" + TimeRange.MONTH -> "$months/$years" + } + } + } + + val state = GraphState(events, dayCounter, startUnix, endUnix, startSpan, endSpan) + + when (graphTypeSelection) { + GraphType.BOTTLE_EVENTS, + GraphType.BOTTLE_SUM -> showBottleBarGraph(state) + GraphType.SLEEP_EVENTS, + GraphType.SLEEP_SUM -> showSleepBarGraph(state) + GraphType.SLEEP_PATTERN -> if (timeRangeSelection == TimeRange.DAY) { + // specialized pattern bar for daily view (optional) + showSleepPatternBarGraphDaily(state) + } else { + showSleepPatternBarGraphSlotted(state) + } + } + } + + 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@LongTermStatisticsActivity, + "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 = "LongTermStatisticsActivity" + + // 15 min steps + const val SLEEP_PATTERN_GRANULARITY = 15 * 60 + + // Time between toast messages (to prevent jams) + const val TOAST_FREQUENCY_MS = 3500 + + // color gradient + val SLEEP_PATTERN_COLORS = arrayOf( + "#FFFFFF".toColorInt(), "#EEF5F7".toColorInt(), "#DDEBEF".toColorInt(), "#CCE2E7".toColorInt(), + "#BBD8DF".toColorInt(), "#AACED7".toColorInt(), "#99C4CF".toColorInt(), "#88BAC7".toColorInt(), + "#77B1BF".toColorInt(), "#66A7B7".toColorInt(), "#559DAF".toColorInt(), "#4493A7".toColorInt(), + "#33899F".toColorInt(), "#228097".toColorInt(), "#11768F".toColorInt(), "#006C87".toColorInt() + ) + + var graphTypeSelection = GraphType.SLEEP_SUM + var timeRangeSelection = TimeRange.DAY + + private val dateTime = Calendar.getInstance() // scratch pad + + // convert month to seconds since epoch + fun unixToMonths(seconds: Long): Int { + dateTime.time = Date(seconds * 1000) + val years = dateTime.get(Calendar.YEAR) + val months = dateTime.get(Calendar.MONTH) + return 12 * years + months + } + + // convert month to seconds since epoch + fun monthsToUnix(months: Int): Long { + 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 + } + + // convert seconds to weeks since epoch + fun unixToWeeks(seconds: Long): Int { + dateTime.time = Date(seconds * 1000) + val years = dateTime.get(Calendar.YEAR) - 1970 + val weeks = dateTime.get(Calendar.WEEK_OF_YEAR) + val month = dateTime.get(Calendar.MONTH) + 1 // month starts at 0 + + if (month == 12 && weeks == 1) { + // The first week if the year might start in the previous year. + return 52 * (years + 1) + weeks + } + + return 52 * years + weeks + } + + // convert weeks to seconds since epoch + fun weeksToUnix(weeks: Int): Long { + dateTime.time = Date(0) + dateTime.set(Calendar.YEAR, 1970 + 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 + } + + // convert seconds to days since epoch + fun unixToDays(seconds: Long): Int { + dateTime.time = Date(seconds * 1000) + val years = dateTime.get(Calendar.YEAR) + val days = dateTime.get(Calendar.DAY_OF_YEAR) + return 365 * years + days + } + + // convert days to seconds since epoch + fun daysToUnix(days: Int): Long { + 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 + } + + // for debugging + fun debugBarValues(values: ArrayList) { + for (value in values) { + val yVals = value.yVals + if (yVals != null) { + val y = yVals.fold(0F) { acc, next -> acc + next } + val yVals = yVals.joinToString { it.toString() } + Log.d(TAG, "value: ${value.x} $y ($yVals)") + } else { + Log.d(TAG, "value: ${value.x} ${value.y}") + } + } + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt index ba1ee8b..4094eec 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.os.Handler +import android.text.Editable import android.util.Log import android.view.LayoutInflater import android.view.View @@ -50,6 +51,8 @@ class MainActivity : AppCompatActivity() { const val TAG = "MainActivity" const val UPDATE_EVERY_SECS: Long = 30 const val DEBUG_CHECK_LOGBOOK_CONSISTENCY = false + // list of all events + var allEvents = arrayListOf() } var logbook: Logbook? = null @@ -145,7 +148,8 @@ class MainActivity : AppCompatActivity() { if (logbook == null) Log.w(TAG, "showLogbook(): logbook is null!") - setListAdapter(logbook?.logs ?: arrayListOf()) + allEvents = logbook?.logs ?: arrayListOf() + setListAdapter(allEvents) } override fun onStart() { @@ -192,10 +196,6 @@ class MainActivity : AppCompatActivity() { super.onStop() } - fun getAllEvents(): ArrayList { - return logbook?.logs ?: arrayListOf() - } - fun addBabyBottleEvent(event: LunaEvent) { setToPreviousQuantity(event) askBabyBottleContent(event, true) { @@ -363,7 +363,7 @@ class MainActivity : AppCompatActivity() { } fun saveEvent(event: LunaEvent) { - if (!getAllEvents().contains(event)) { + if (!allEvents.contains(event)) { // new event logEvent(event) } @@ -566,7 +566,7 @@ class MainActivity : AppCompatActivity() { val nextTextView = dialogView.findViewById(R.id.notes_template_next) val prevTextView = dialogView.findViewById(R.id.notes_template_prev) - val templates = getAllEvents().filter { it.type == event.type }.distinctBy { it.notes }.sortedBy { it.time } + val templates = allEvents.filter { it.type == event.type }.distinctBy { it.notes }.sortedBy { it.time } fun updateContent(current: LunaEvent) { val prevEvent = getPreviousSameEvent(current, templates) @@ -672,7 +672,7 @@ class MainActivity : AppCompatActivity() { } fun setToPreviousQuantity(event: LunaEvent) { - val prev = getPreviousSameEvent(event, getAllEvents()) + val prev = getPreviousSameEvent(event, allEvents) if (prev != null) { event.quantity = prev.quantity } @@ -817,8 +817,6 @@ class MainActivity : AppCompatActivity() { signatureTextEdit.visibility = View.VISIBLE } - val allEvents = getAllEvents() - // create link to prevent event of the same type val previousTextView = dialogView.findViewById(R.id.dialog_event_previous) val previousEvent = getPreviousSameEvent(event, allEvents) @@ -1238,6 +1236,16 @@ class MainActivity : AppCompatActivity() { addPlainEvent(LunaEvent(LunaEvent.TYPE_BATH)) dismiss() } + contentView.findViewById(R.id.button_statistics).setOnClickListener { + if (logbook != null && !pauseLogbookUpdate) { + val i = Intent(applicationContext, LongTermStatisticsActivity::class.java) + i.putExtra("LOOGBOOK_NAME", logbook!!.name) + startActivity(i) + } else { + Toast.makeText(applicationContext, "No logbook selected!", Toast.LENGTH_SHORT).show() + } + dismiss() + } }.also { popupWindow -> popupWindow.setOnDismissListener({ Handler(mainLooper).postDelayed({ diff --git a/app/src/main/java/utils/NumericUtils.kt b/app/src/main/java/utils/NumericUtils.kt index e9f130b..d35ae4f 100644 --- a/app/src/main/java/utils/NumericUtils.kt +++ b/app/src/main/java/utils/NumericUtils.kt @@ -63,28 +63,31 @@ class NumericUtils (val context: Context) { } fun formatEventQuantity(event: LunaEvent): String { + return formatEventQuantity(event.type, event.quantity) + } + + fun formatEventQuantity(type: String, quantity: Int): String { val formatted = StringBuilder() - if (event.quantity > 0) { - formatted.append(when (event.type) { + if (quantity > 0) { + formatted.append(when (type) { LunaEvent.TYPE_TEMPERATURE -> - (event.quantity / 10.0f).toString() + (quantity / 10.0f).toString() LunaEvent.TYPE_DIAPERCHANGE_POO, LunaEvent.TYPE_DIAPERCHANGE_PEE, LunaEvent.TYPE_PUKE -> { val array = context.resources.getStringArray(R.array.AmountLabels) - return array.getOrElse(event.quantity) { - Log.e("NumericUtils", "Invalid index ${event.quantity}") + return array.getOrElse(quantity) { + Log.e("NumericUtils", "Invalid index $quantity") return "" } } - LunaEvent.TYPE_SLEEP -> formatTimeDuration(context, event.quantity.toLong()) - else -> - event.quantity + LunaEvent.TYPE_SLEEP -> formatTimeDuration(context, quantity.toLong()) + else -> quantity }) formatted.append(" ") formatted.append( - when (event.type) { + when (type) { LunaEvent.TYPE_BABY_BOTTLE -> measurement_unit_liquid_base LunaEvent.TYPE_WEIGHT -> measurement_unit_weight_base LunaEvent.TYPE_MEDICINE -> measurement_unit_weight_tiny @@ -93,7 +96,7 @@ class NumericUtils (val context: Context) { } ) } else { - formatted.append(when (event.type) { + formatted.append(when (type) { LunaEvent.TYPE_SLEEP -> "💤" // baby is sleeping else -> "" }) diff --git a/app/src/main/res/layout/activity_statistics.xml b/app/src/main/res/layout/activity_statistics.xml new file mode 100644 index 0000000..94290d2 --- /dev/null +++ b/app/src/main/res/layout/activity_statistics.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_event_details.xml b/app/src/main/res/layout/dialog_event_details.xml index 99c678f..abbc153 100644 --- a/app/src/main/res/layout/dialog_event_details.xml +++ b/app/src/main/res/layout/dialog_event_details.xml @@ -1,6 +1,5 @@ + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 87a93d3..51c918f 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,4 +6,32 @@ @string/amount_normal @string/amount_plenty + + + @string/statistics_bottle_sum + @string/statistics_bottle_events + @string/statistics_sleep_sum + @string/statistics_sleep_events + @string/statistics_sleep_pattern + + + + BOTTLE_SUM + BOTTLE_EVENTS + SLEEP_SUM + SLEEP_EVENTS + SLEEP_PATTERN + + + + Day + Week + Month + + + + DAY + WEEK + MONTH + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index df2deff..7ca3983 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,9 @@ Settings Retry + Statistics + No Data + Settings Signature Attach a signature to each event you create and for others to see. Useful if multiple people add events. @@ -128,6 +131,13 @@ °F °C + Bottle Events + Bottle Per Day + Medicine Events + Sleep Per Day + Sleep Events + Sleep Pattern + Description Qty Time diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bb41e7..4647a16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" composeBom = "2024.04.01" appcompat = "1.7.0" +mpandroidchart = "v4.2.2" +mpandroidchartVersion = "v3.1.0" recyclerview = "1.3.2" material = "1.12.0" @@ -30,6 +32,8 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +mpandroidchart = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpandroidchart" } +mpandroidchart-vv310 = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpandroidchartVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4bf8c41..81ca610 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,7 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven(url = "https://jitpack.io") + maven(url = uri("https://jitpack.io")) } }