From a0dc02394175861e96704c441bdf7b6aef84a5dd Mon Sep 17 00:00:00 2001 From: Moritz Warning Date: Thu, 6 Nov 2025 21:41:36 +0100 Subject: [PATCH] add statistics for bottle and sleep events --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 4 + .../lunatracker/MainActivity.kt | 28 +- .../lunatracker/StatisticsActivity.kt | 374 ++++++++++++++++++ app/src/main/java/utils/NumericUtils.kt | 23 +- .../main/res/layout/activity_statistics.xml | 57 +++ .../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 | 33 ++ app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 4 + settings.gradle.kts | 2 +- 13 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.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..b335ff9 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/MainActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt index 76fbf03..4c87ede 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) } @@ -556,7 +556,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) @@ -662,7 +662,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 } @@ -807,8 +807,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) @@ -1228,6 +1226,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, StatisticsActivity::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/it/danieleverducci/lunatracker/StatisticsActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt new file mode 100644 index 0000000..fe26f14 --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt @@ -0,0 +1,374 @@ +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.DateUtils.Companion.formatTimeDuration +import utils.NumericUtils +import java.util.Calendar +import java.util.Date +import kotlin.math.max +import kotlin.math.min + +class StatisticsActivity : AppCompatActivity() { + lateinit var barChart: BarChart + lateinit var noDataTextView: TextView + lateinit var eventTypeSelection: Spinner + lateinit var dataTypeSelection: Spinner + lateinit var timeRangeSelection: Spinner + + // default selection + var eventTypeSelectionValue = "BOTTLE" + var dataTypeSelectionValue = "AMOUNT" + var timeRangeSelectionValue = "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) + + eventTypeSelection = findViewById(R.id.type_selection) + dataTypeSelection = findViewById(R.id.data_selection) + timeRangeSelection = findViewById(R.id.time_selection) + + setupSpinner(eventTypeSelectionValue, + R.id.type_selection, + R.array.StatisticsTypeLabels, + R.array.StatisticsTypeValues, + object : SpinnerItemSelected { + override fun call(newValue: String?) { + if (newValue != null) { + eventTypeSelectionValue = newValue + //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(timeRangeSelectionValue, + R.id.time_selection, + R.array.StatisticsTimeLabels, + R.array.StatisticsTimeValues, + object : SpinnerItemSelected { + override fun call(newValue: String?) { + if (newValue != null) { + timeRangeSelectionValue = newValue + //Log.d("event", "new value: $newValue") + updateGraph() + } + } + } + ) + + updateGraph() + } + + fun updateGraph() { + val eventType = when (eventTypeSelectionValue) { + "BOTTLE" -> LunaEvent.TYPE_BABY_BOTTLE + "SLEEP" -> LunaEvent.TYPE_SLEEP + else -> { + Log.e(TAG, "unhandled eventTypeSelectionValue: $eventTypeSelectionValue") + return + } + } + val allEvents = MainActivity.allEvents.filter { it.type == eventType }.sortedBy { it.time } + + val values = ArrayList() + val labels = ArrayList() + + val unixToSpan = when (timeRangeSelectionValue) { + "DAY" -> { unix: Long -> unixToDays(unix) } + "WEEK" -> { unix: Long -> unixToWeeks(unix) } + "MONTH" -> { unix: Long -> unixToMonths(unix) } + else -> { + Log.e(TAG, "Invalid timeRangeSelectionValue: $timeRangeSelectionValue") + return + } + } + + val spanToUnix = when (timeRangeSelectionValue) { + "DAY" -> { span: Int -> daysToUnix(span) } + "WEEK" -> { span: Int -> weeksToUnix(span) } + "MONTH" -> { span: Int -> monthsToUnix(span) } + else -> { + Log.e(TAG, "Invalid timeRangeSelectionValue: $timeRangeSelectionValue") + return + } + } + + fun spanToLabel(span: Int): String { + val dateTime = Calendar.getInstance() + dateTime.time = Date(1000 * spanToUnix(span)) + 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 (timeRangeSelectionValue) { + "DAY" -> "$day/$month/$year" + "WEEK" -> "$week/$year" + "MONTH" -> "$month/$year" + else -> { + Log.e(TAG, "Invalid timeRangeSelectionValue: $timeRangeSelectionValue") + "?" + } + } + } + + if (allEvents.isNotEmpty()) { + barChart.visibility = View.VISIBLE + noDataTextView.visibility = View.GONE + + // unix time span of all events + val startUnix = allEvents.minOf { it.getStartTime() } + val endUnix = allEvents.maxOf { it.getEndTime() } + + // convert to days, weeks or months + val startSpan = unixToSpan(startUnix) + val endSpan = unixToSpan(endUnix) + + //Log.d(TAG, "startUnix: $startUnix (${Date(1000 * startUnix)}), startSpan: $startSpan (${Date(1000 * spanToUnix(startSpan))}), endUnix: $endUnix (${Date(1000 * endUnix)}), endSpan: $endSpan (${Date(1000 * spanToUnix(endSpan))})") + for (span in startSpan..endSpan) { + values.add(BarEntry(values.size.toFloat(), 0F)) + labels.add(spanToLabel(span)) + } + + for (event in allEvents) { + if (dataTypeSelectionValue == "AMOUNT") { + if (eventTypeSelectionValue == "SLEEP") { + // a sleep event can span to another day + // distribute sleep time over the days + val startUnix = event.getStartTime() + val endUnix = event.getEndTime() + 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 beg = max(mid, spanBegin) + val end = min(endUnix, spanEnd) + val index = i - startSpan + val duration = end - beg + //Log.d(TAG, "[$index] beg: ${Date(beg * 1000)}, end: ${Date(end * 1000)}, ${formatTimeDuration(this, duration)}") + + values[index].y += duration + mid = end + } + } else { + val index = unixToSpan(event.time) - startSpan + //Log.d(TAG, "[${index}] ${event.quantity}") + values[index].y += event.quantity + } + } else { + val index = unixToSpan(event.time) - startSpan + values[index].y += 1 + } + } + } else { + barChart.visibility = View.GONE + noDataTextView.visibility = View.VISIBLE + } + + barChart.xAxis.setLabelCount(min(values.size, 24)) + + val set1 = BarDataSet(values, "") + + set1.setDrawValues(true) + set1.setDrawIcons(false) + + 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 (dataTypeSelectionValue) { + "EVENT" -> value.toInt().toString() + "AMOUNT" -> when (eventTypeSelectionValue) { + "BOTTLE" -> NumericUtils(applicationContext).formatEventQuantity(LunaEvent.TYPE_BABY_BOTTLE, value.toInt()) + "SLEEP" -> NumericUtils(applicationContext).formatEventQuantity(LunaEvent.TYPE_SLEEP, value.toInt()) + else -> { + Log.e(TAG, "unhandled eventTypeSelectionValue: $eventTypeSelectionValue") + value.toInt().toString() + } + } else -> { + Log.e(TAG, "unhandled dataTypeSelectionValue: $dataTypeSelectionValue") + 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() + } + + 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 + } + } +} 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..d9177f0 --- /dev/null +++ b/app/src/main/res/layout/activity_statistics.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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..3027a83 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,4 +6,37 @@ @string/amount_normal @string/amount_plenty + + + Bottle + Sleep + + + + BOTTLE + SLEEP + + + + Event + Amount + + + + EVENT + AMOUNT + + + + 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 72ce19f..bbeac28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,8 @@ Settings Retry + Statistics + Settings Signature Attach a signature to each event you create and for others to see. Useful if multiple people add events. 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")) } }