statistics

This commit is contained in:
2025-11-06 21:41:36 +01:00
parent 11a4f12fbe
commit 3e6a92fb14
13 changed files with 1038 additions and 13 deletions

View File

@@ -60,4 +60,7 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation("com.github.PhilJay:MPAndroidChart:v3.1.0")
// implementation(libs.mpandroidchart)
//implementation project(':MPChartLib')
}

View File

@@ -30,6 +30,10 @@
android:name=".SettingsActivity"
android:label="@string/settings_title"
android:theme="@style/Theme.LunaTracker"/>
<activity
android:name=".StatisticsActivity"
android:label="@string/statistics_title"
android:theme="@style/Theme.LunaTracker"/>
</application>
</manifest>

View File

@@ -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
@@ -1229,6 +1230,16 @@ class MainActivity : AppCompatActivity() {
addPlainEvent(LunaEvent(LunaEvent.TYPE_BATH))
dismiss()
}
contentView.findViewById<View>(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({

View File

@@ -0,0 +1,885 @@
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 com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.MainActivity.Companion.TAG
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener
import it.danieleverducci.lunatracker.repository.LogbookLoadedListener
import it.danieleverducci.lunatracker.repository.LogbookRepository
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import okio.IOException
import org.json.JSONException
import utils.DateUtils.Companion.formatTimeDuration
import utils.NumericUtils
import java.util.Calendar
import java.util.Date
import kotlin.math.max
import kotlin.math.min
abstract class LogbookBase: AppCompatActivity() {
var logbook: Logbook? = null
var logbookName: String? = null
lateinit var logbookRepo: LogbookRepository
fun initLogbookBase() {
Log.d(TAG, "LogbookBase init")
val settingsRepository = LocalSettingsRepository(this)
if (settingsRepository.loadDataRepository() == LocalSettingsRepository.DATA_REPO.WEBDAV) {
val webDavCredentials = settingsRepository.loadWebdavCredentials()
if (webDavCredentials == null) {
throw IllegalStateException("Corrupted local settings: repo is webdav, but no webdav login data saved")
}
logbookRepo = WebDAVLogbookRepository(
webDavCredentials[0],
webDavCredentials[1],
webDavCredentials[2]
)
} else {
logbookRepo = FileLogbookRepository()
}
if (logbook != null) {
// Already running: reload data for currently selected logbook
loadLogbook(logbook!!.name)
} else {
// First start: load logbook list
loadLogbookList()
}
}
abstract fun onLogbookReady()
fun loadLogbook(name: String) {
//if (savingEvent)
// return
// Reset time counter
//handler.removeCallbacks(updateListRunnable)
//handler.postDelayed(updateListRunnable, UPDATE_EVERY_SECS*1000)
// Load data
//setLoading(true)
logbookRepo?.loadLogbook(this, name, object: LogbookLoadedListener{
override fun onLogbookLoaded(lb: Logbook) {
runOnUiThread({
Log.d("StatisticsActivity", "logbook loaded!")
//setLoading(false)
//findViewById<View>(R.id.no_connection_screen).visibility = View.GONE
logbook = lb
//val events = logbook?.logs ?: arrayListOf()
onLogbookReady()//updateGraph() //events) // showLogbook()
/*
if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) {
for (e in logbook?.logs ?: listOf()) {
val em = e.getTypeEmoji(this@MainActivity)
if (em == getString(R.string.event_unknown_type)) {
Log.e(TAG, "UNKNOWN: ${e.type}")
}
}
}
*/
})
}
override fun onIOError(error: IOException) {
runOnUiThread({
//setLoading(false)
onRepoError(getString(R.string.settings_network_error) + error.toString())
})
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread({
//setLoading(false)
onRepoError(
if(error.toString().contains("401")) {
getString(R.string.settings_webdav_error_denied)
} else if(error.toString().contains("503")) {
getString(R.string.settings_webdav_error_server_offline)
} else {
getString(R.string.settings_webdav_error_generic) + error.toString()
}
)
})
}
override fun onJSONError(error: JSONException) {
runOnUiThread({
//setLoading(false)
onRepoError(getString(R.string.settings_json_error) + error.toString())
})
}
override fun onError(error: Exception) {
runOnUiThread({
//setLoading(false)
onRepoError(getString(R.string.settings_generic_error) + error.toString())
})
}
})
}
fun loadLogbookList() {
//setLoading(true)
logbookRepo?.listLogbooks(this, object: LogbookListObtainedListener {
override fun onLogbookListObtained(logbooksNames: ArrayList<String>) {
if (logbooksNames.isNotEmpty()) {
loadLogbook(logbooksNames[0])
}
/*
runOnUiThread({
if (logbooksNames.isEmpty()) {
// First run, no logbook: create one
//showAddLogbookDialog(false)
return@runOnUiThread
}
// Show logbooks dropdown
val spinner = findViewById<Spinner>(R.id.logbooks_spinner)
val sAdapter = ArrayAdapter<String>(this@StatisticsActivity, android.R.layout.simple_spinner_item)
sAdapter.setDropDownViewResource(R.layout.row_logbook_spinner)
for (ln in logbooksNames) {
sAdapter.add(
ln.ifEmpty { getString(R.string.default_logbook_name) }
)
}
spinner.adapter = sAdapter
spinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
// Changed logbook: empty list
setListAdapter(arrayListOf())
// Load logbook
loadLogbook(logbooksNames.get(position))
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
})
*/
}
override fun onIOError(error: IOException) {
Log.e(TAG, "Unable to load logbooks list (IOError): $error")
runOnUiThread({
//setLoading(false)
onRepoError(getString(R.string.settings_network_error) + error.toString())
})
}
override fun onWebDAVError(error: SardineException) {
Log.e(TAG, "Unable to load logbooks list (SardineException): $error")
runOnUiThread({
//setLoading(false)
onRepoError(
if(error.toString().contains("401")) {
getString(R.string.settings_webdav_error_denied)
} else if(error.toString().contains("503")) {
getString(R.string.settings_webdav_error_server_offline)
} else {
getString(R.string.settings_webdav_error_generic) + error.toString()
}
)
})
}
override fun onError(error: Exception) {
Log.e(TAG, "Unable to load logbooks list: $error")
runOnUiThread({
//setLoading(false)
onRepoError(getString(R.string.settings_generic_error) + error.toString())
})
}
})
}
fun onRepoError(message: String) {
runOnUiThread({
//setLoading(false)
//findViewById<View>(R.id.no_connection_screen).visibility = View.VISIBLE
//findViewById<TextView>(R.id.no_connection_screen_message).text = message
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
})
}
fun getAllEvents(): ArrayList<LunaEvent> {
return logbook?.logs ?: arrayListOf()
}
}
open class StatisticsActivity : LogbookBase() {
lateinit var barChart: BarChart
lateinit var noDataTextView: TextView
lateinit var eventTypeSelection: Spinner
lateinit var dataTypeSelection: Spinner
lateinit var timeRangeSelection: Spinner
var eventTypeSelectionValue = "BOTTLE"
var dataTypeSelectionValue = "AMOUNT"
var timeRangeSelectionValue = "DAY"
override fun onLogbookReady() {
updateGraph()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_statistics)
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.description.isEnabled = false
barChart.axisLeft.setAxisMinimum(0F)
/*
barChart.setFitBars(true)
barChart.setMaxVisibleValueCount(30)
barChart.axisLeft.setDrawLabels(false)
barChart.axisRight.setDrawLabels(true)
barChart.xAxis.setDrawLabels(true)
barChart.legend.setEnabled(false)
barChart.setDrawValueAboveBar(true)
//barChart.setVisibleXRangeMaximum(30f)
*/
/*
val seekBar = findViewById<SeekBar>(R.id.seekBar);
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar?,
progress: Int,
fromUser: Boolean
) {
//tvX.setText(java.lang.String.valueOf(seekBar.getProgress()))
//tvY.setText(java.lang.String.valueOf(seekBarY.getProgress()))
//setData(seekBarX.getProgress(), seekBarY.getProgress())
barChart.setFitBars(true)
barChart.invalidate()
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
*/
/*
// if more than 60 entries are displayed in the barChart, no values will be
// drawn
//barChart.setMaxVisibleValueCount(60)
barChart.setScaleMinima(2f, 1f)
// scaling can now only be done on x- and y-axis separately
barChart.setPinchZoom(true)
// draw shadows for each bar that show the maximum value
barChart.setDrawBarShadow(true)
barChart.setDrawGridBackground(true)
val xl = barChart.getXAxis()
xl.position = XAxisPosition.BOTTOM
//xl.setTypeface(tfLight)
xl.setDrawAxisLine(true)
xl.setDrawGridLines(false)
//xl.setGranularity(10f)
// yl.setInverted(true);
val yr = barChart.axisRight
//yr.setTypeface(tfLight)
yr.setDrawAxisLine(true)
yr.setDrawGridLines(false)
yr.setAxisMinimum(0f) // this replaces setStartAtZero(true)
// yr.setInverted(true);
barChart.setFitBars(true)
barChart.animateY(500)
*/
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()
}
}
}
)
initLogbookBase()
}
/*
fun massageData(): Triple<Float, Float, Float>
Triple(, ,)
)
*/
fun updateGraph() {
//eventTypeSelectionValue = "SLEEP"
//dataTypeSelectionValue = "AMOUNT"
//timeRangeSelectionValue = "DAY"
//val allEvents = getAllEvents()
//Log.d("StatisticsActivity", "updateGraph: allEvents: ${allEvents.size}")
Log.d("StatisticsActivity", "eventTypeSelectionValue: $eventTypeSelectionValue, dataTypeSelectionValue: $dataTypeSelectionValue, timeRange: $timeRangeSelectionValue")
// for quantity
fun formatEventValue(value: Float): String {
return 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, "invalid dataTypeSelectionValue: $dataTypeSelectionValue")
"${value.toInt()}"
}
}
}
val eventType = when (eventTypeSelectionValue) {
"BOTTLE" -> LunaEvent.TYPE_BABY_BOTTLE
"SLEEP" -> LunaEvent.TYPE_SLEEP
else -> {
Log.e(TAG, "Invalid eventTypeSelectionValue: $eventTypeSelectionValue")
return
}
}
val allEvents = getAllEvents().filter { it.type == eventType }.sortedBy { it.time }
Log.d("StatisticsActivity", "events: ${allEvents.size}")
val values = ArrayList<BarEntry>()
val labels = ArrayList<String>()
val unixToSpan: ((Long) -> Int) = { unix ->
when (timeRangeSelectionValue) {
"DAY" -> unixToDays(unix)
"WEEK" -> unixToWeeks(unix)
"MONTH" -> unixToMonths(unix)
else -> {
Log.e(TAG, "Invalid timeRangeSelectionValue: $timeRangeSelectionValue")
0
}
}
}
val spanToUnix: ((Int) -> Long) = { span ->
when (timeRangeSelectionValue) {
"DAY" -> daysToUnix(span)
"WEEK" -> weeksToUnix(span)
"MONTH" -> monthToUnix(span)
else -> {
Log.e(TAG, "Invalid timeRangeSelectionValue: $timeRangeSelectionValue")
0
}
}
}
fun spanToLabel(span: Int): String {
//simpleDateFormat = SimpleDateFormat("dd/MM/yyyy")
//val dateTime = simpleDateFormat.format(Date(1000 * spanToUnix(span))).toString()
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")
"?"
}
}
}
/*
fun calcDay(seconds: Long): Long {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm")
val dateTime = Calendar.getInstance()
dateTime.time = Date(seconds * 1000)
val before = formatter.format(dateTime.time)
dateTime.set(Calendar.SECOND, 0)
dateTime.set(Calendar.MINUTE, 0)
dateTime.set(Calendar.HOUR, 0)
dateTime.set(Calendar.HOUR_OF_DAY, 0)
val after = formatter.format(dateTime.time)
//Log.d(TAG, "calcDay: $before -> $after")
return dateTime.time.time / 1000
}
*/
if (allEvents.isNotEmpty()) {
barChart.visibility = View.VISIBLE
noDataTextView.visibility = View.GONE
// unix time span of all events
val startUnix = allEvents.minOf { it.time }
val endUnix = if (dataTypeSelectionValue == "AMOUNT" && eventTypeSelectionValue == "SLEEP") {
allEvents.maxOf { it.time + it.quantity }
} else {
allEvents.maxOf { it.time }
}
// convert to days, weeks or months
val startSpan = unixToSpan(startUnix)
val endSpan = unixToSpan(endUnix)
//Log.d(TAG, "startSpan: $startSpan (${Date(1000 * allEvents.first().time)}), endSpan: $endSpan (${Date(1000 * allEvents.last().time)})")
//Log.d(TAG, "start: ${Date(1000 * spanToUnix(startSpan))}, end: ${Date(1000 * spanToUnix(endSpan))}")
for (span in startSpan..endSpan + 1) {
//Log.d(TAG, "step: ${Date(1000 * daysToUnix(span))}") // todo: checj month
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
// iterate over indexes
// sleep covers a time range, distribute range of time spans
//var mid = spanToUnix(startIndex)
val startUnix = event.time
val endUnix = event.time + event.quantity
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
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
}
val set1 = BarDataSet(values, "sums") // change to "sums", "events", "" and set logbookName to
set1.setDrawValues(true)
set1.setDrawIcons(false)
//Log.d("StatisticsActivity", "values.size: ${values.size}, labels.size: ${labels.size}")
//Log.d("StatisticsActivity", "values: ${values}, labels: ${labels}")
/*
val set1: BarDataSet
if (barChart.data != null &&
barChart.data.getDataSetCount() > 0
) {
set1 = barChart.data.getDataSetByIndex(0) as BarDataSet
set1.setValues(values)
barChart.data.notifyDataChanged()
barChart.notifyDataSetChanged()
} else {
*/
val dataSets = ArrayList<IBarDataSet?>()
dataSets.add(set1)
val data = BarData(dataSets)
//data.setValueTextSize(10f)
//data.setValueTypeface(tfLight)
//data.barWidth = 9f
data.setValueFormatter(object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
if (dataTypeSelectionValue == "AMOUNT") {
//Log.d(TAG, "formatEventValue: ${formatEventValue(value)}")
//Log.d(TAG, "data.setValueFormatter")
return formatEventValue(value)
} else {
return value.toInt().toString()
}
}
})
//barChart.xAxis.setLabelsToSkip(0)
//val leftAxis = barChart.axisLeft
//barChart.xAxis.setLabelCount(values.size)
barChart.axisRight.setDrawLabels(false)
barChart.axisLeft.setDrawLabels(false)
barChart.xAxis.valueFormatter = object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
//Log.d("labels", "labels: ${value.toInt()}")
return labels.getOrElse(value.toInt(), {"?"})
}
}
//val labelsTmp = values.map { "${it.y.toInt()} ml" } // ArrayList<String>()
//Log.d("StatisticsActivity", "labels: $labelsTmp")
//barChart.xAxis.setGranularity(1f)
//barChart.xAxis.isGranularityEnabled = true
//barChart.xAxis.setValueFormatter(IndexAxisValueFormatter(labels))
/*
barChart.axisRight.valueFormatter = object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
//Log.d(TAG, "formatEventValue: ${formatEventValue(value)}")
return formatEventValue(value)
}
}
*/
//barChart.axisRight.setGranularity(1.0f)
//barChart.axisRight.isGranularityEnabled = true
/*
barChart.getYLabels().valueFormatter = object : ValueFormatter() {
override fun getFormattedValue(value: Float): String {
Log.d("StatisticsActivity", "y value: $value")
return value.toString() + "ml"
}
}
*/
//barChart.axisLeft.textColor = Color.WHITE
//barChart.axisRight.textColor = Color.WHITE
//barChart.setNoDataTextColor(Color.RED)
//barChart.setBackgroundColor()
//data.setValueTextSize(10f)
//data.setValueTypeface(tfLight)
//data.setBarWidth(9f)
//data.setValueTextColor(Color.WHITE)
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<Spinner>(spinnerId)
val spinnerAdapter =
ArrayAdapter.createFromResource(this, arrayId, R.layout.statistics_spinner_item) //android.R.layout.simple_spinner_item) //android.R.layout.simple_list_item_single_choice) //R.layout.spinner_item_settings)
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
}
}
}
/*
fun loadSettings() {
val dataRepo = settingsRepository.loadDataRepository()
val webDavCredentials = settingsRepository.loadWebdavCredentials()
val noBreastfeeding = settingsRepository.loadNoBreastfeeding()
val signature = settingsRepository.loadSignature()
when (dataRepo) {
LocalSettingsRepository.DATA_REPO.LOCAL_FILE -> radioDataLocal.isChecked = true
LocalSettingsRepository.DATA_REPO.WEBDAV -> radioDataWebDAV.isChecked = true
}
textViewSignature.setText(signature)
switchNoBreastfeeding.isChecked = noBreastfeeding
if (webDavCredentials != null) {
textViewWebDAVUrl.text = webDavCredentials[0]
textViewWebDAVUser.text = webDavCredentials[1]
textViewWebDAVPass.text = webDavCredentials[2]
}
}
fun validateAndSave() {
if (radioDataLocal.isChecked) {
// No validation required, just save
saveSettings()
return
}
// Try to connect to WebDAV and check if the save file already exists
val webDAVLogbookRepo = WebDAVLogbookRepository(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
progressIndicator.visibility = View.VISIBLE
webDAVLogbookRepo.listLogbooks(this, object: LogbookListObtainedListener{
override fun onLogbookListObtained(logbooksNames: ArrayList<String>) {
if (logbooksNames.isEmpty()) {
// TODO: Ask the user if he wants to upload the local ones or to create a new one
copyLocalLogbooksToWebdav(webDAVLogbookRepo, object: OnCopyLocalLogbooksToWebdavFinishedListener {
override fun onCopyLocalLogbooksToWebdavFinished(errors: String?) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if (errors == null) {
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, errors, Toast.LENGTH_SHORT).show()
}
})
}
})
} else {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
})
}
}
override fun onIOError(error: IOException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_network_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if(error.toString().contains("401")) {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_denied), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_generic) + error.toString(), Toast.LENGTH_SHORT).show()
}
})
}
override fun onError(error: Exception) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_generic_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
})
}
fun saveSettings() {
settingsRepository.saveDataRepository(
if (radioDataWebDAV.isChecked) LocalSettingsRepository.DATA_REPO.WEBDAV
else LocalSettingsRepository.DATA_REPO.LOCAL_FILE
)
settingsRepository.saveNoBreastfeeding(switchNoBreastfeeding.isChecked)
settingsRepository.saveSignature(textViewSignature.text.toString())
settingsRepository.saveWebdavCredentials(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
finish()
}
private fun copyLocalLogbooksToWebdav(webDAVLogbookRepository: WebDAVLogbookRepository, listener: OnCopyLocalLogbooksToWebdavFinishedListener) {
Thread(Runnable {
val errors = StringBuilder()
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
for (logbook in logbooks) {
// Copy only if does not already exist
val error = webDAVLogbookRepository.uploadLogbookIfNotExists(this, logbook.name)
if (error != null) {
if (errors.isNotEmpty())
errors.append("\n")
errors.append(String.format(getString(R.string.settings_webdav_upload_error), logbook.name, error))
}
}
listener.onCopyLocalLogbooksToWebdavFinished(
if (errors.isEmpty()) null else errors.toString()
)
}).start()
}
private interface OnCopyLocalLogbooksToWebdavFinishedListener {
fun onCopyLocalLogbooksToWebdavFinished(errors: String?)
}
*/
companion object {
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) // January is 0
return 12 * years + 1 + months
}
fun monthToUnix(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.DAY_OF_YEAR, weeks % 52)
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
}
}
}

View File

@@ -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 -> ""
})

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1">
<com.github.mikephil.charting.charts.HorizontalBarChart
android:id="@+id/bar_chart"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/no_data"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:gravity="center"
android:text="No Data"/>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<Spinner
android:id="@+id/type_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Spinner
android:id="@+id/data_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<Spinner
android:id="@+id/time_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"/>
</LinearLayout>
</LinearLayout>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@@ -10,10 +10,21 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/button_statistics"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="10dp"
android:padding="10dp"
android:background="@drawable/dropdown_list_item_background"
style="@style/OverflowMenuText"
android:text="📊 Statistics"/>
<TextView
android:id="@+id/button_medicine"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="10dp"
android:padding="10dp"
android:background="@drawable/dropdown_list_item_background"
style="@style/OverflowMenuText"

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="20sp"
android:gravity="center"
android:padding="5dip" />

View File

@@ -6,4 +6,44 @@
<item>@string/amount_normal</item>
<item>@string/amount_plenty</item>
</string-array>
<string-array name="StatisticsTypeLabels">
<item>Bottle</item>
<item>Sleep</item>
</string-array>
<string-array name="StatisticsTypeValues">
<item>BOTTLE</item>
<item>SLEEP</item>
</string-array>
<string-array name="StatisticsDataLabels">
<item>Event</item>
<item>Amount</item>
</string-array>
<string-array name="StatisticsDataValues">
<item>EVENT</item>
<item>AMOUNT</item>
</string-array>
<string-array name="StatisticsTimeLabels">
<item>Day</item>
<item>Week</item>
<item>Month</item>
</string-array>
<string-array name="StatisticsTimeValues">
<item>DAY</item>
<item>WEEK</item>
<item>MONTH</item>
</string-array>
<!--
<string-array name="speakerphoneModeValues">
<item>auto</item>
<item>on</item>
<item>off</item>
</string-array>
-->
</resources>

View File

@@ -77,6 +77,8 @@
<string name="no_connection_go_to_settings">Settings</string>
<string name="no_connection_retry">Retry</string>
<string name="statistics_title">Statistics</string>
<string name="settings_title">Settings</string>
<string name="settings_signature">Signature</string>
<string name="settings_signature_desc">Attach a signature to each event you create and for others to see. Useful if multiple people add events.</string>

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.13.0"
agp = "8.12.0"
kotlin = "2.0.0"
coreKtx = "1.10.1"
junit = "4.13.2"
@@ -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" }

View File

@@ -16,7 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
maven(url = uri("https://jitpack.io"))
}
}