diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06e1f6a..8ffb82b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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') } 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 fa51683..cfc5bc8 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 @@ -1229,6 +1230,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..7efca1d --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt @@ -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(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) { + 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(R.id.logbooks_spinner) + val sAdapter = ArrayAdapter(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(R.id.no_connection_screen).visibility = View.VISIBLE + //findViewById(R.id.no_connection_screen_message).text = message + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + }) + } + + fun getAllEvents(): ArrayList { + 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(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 + 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() + val labels = ArrayList() + + 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() + 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() + //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(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) { + 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 + } + } +} \ No newline at end of file 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..7a3a41c --- /dev/null +++ b/app/src/main/res/layout/activity_statistics.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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..078345c 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,4 +6,44 @@ @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..eda0e8b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } 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")) } }