11 Commits

20 changed files with 967 additions and 168 deletions

View File

@@ -65,4 +65,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.mpandroidchart.vv310)
} }

View File

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

View File

@@ -86,6 +86,7 @@ class MainActivity : AppCompatActivity() {
recyclerView = findViewById(R.id.list_events) recyclerView = findViewById(R.id.list_events)
recyclerView.setLayoutManager(LinearLayoutManager(applicationContext)) recyclerView.setLayoutManager(LinearLayoutManager(applicationContext))
// set defaults
populateHeaderMenu() populateHeaderMenu()
// Set listeners // Set listeners
@@ -108,11 +109,7 @@ class MainActivity : AppCompatActivity() {
loadLogbookList() loadLogbookList()
} }
findViewById<View>(R.id.button_sync).setOnClickListener { findViewById<View>(R.id.button_sync).setOnClickListener {
if (logbook != null) { loadLogbookList()
loadLogbook(logbook!!.name)
} else {
loadLogbookList()
}
} }
} }
@@ -142,6 +139,7 @@ class MainActivity : AppCompatActivity() {
populateHeaderMenu() populateHeaderMenu()
} }
// populate action rows
private fun populateHeaderMenu() { private fun populateHeaderMenu() {
val settingsRepository = LocalSettingsRepository(this) val settingsRepository = LocalSettingsRepository(this)
val dynamicMenu = settingsRepository.loadDynamicMenu() val dynamicMenu = settingsRepository.loadDynamicMenu()
@@ -155,7 +153,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
// sort all event types by frequency and ordinal // sort all event types by frequency or ordinal
val eventTypesSorted = LunaEvent.Type.entries.toList().sortedWith( val eventTypesSorted = LunaEvent.Type.entries.toList().sortedWith(
compareBy({ -1 * (eventTypeStats[it] ?: 0) }, { it.ordinal }) compareBy({ -1 * (eventTypeStats[it] ?: 0) }, { it.ordinal })
).filter { it != LunaEvent.Type.UNKNOWN } ).filter { it != LunaEvent.Type.UNKNOWN }
@@ -187,7 +185,7 @@ class MainActivity : AppCompatActivity() {
fun show(vararg tvs: TextView) { fun show(vararg tvs: TextView) {
for (tv in tvs) { for (tv in tvs) {
val type = sortedEventTypes[showCounter] val type = sortedEventTypes[showCounter]
tv.text = LunaEvent.getHeaderEmoji(applicationContext, type) tv.text = LunaEvent.getTypeEmoji(applicationContext, type)
tv.setOnClickListener { showCreateDialog(type) } tv.setOnClickListener { showCreateDialog(type) }
tv.visibility = View.VISIBLE tv.visibility = View.VISIBLE
// show parent row // show parent row
@@ -264,7 +262,7 @@ class MainActivity : AppCompatActivity() {
fun askBabyBottleContent(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) { fun askBabyBottleContent(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) {
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_bottle, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_bottle, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
@@ -276,7 +274,7 @@ class MainActivity : AppCompatActivity() {
numberPicker.value = event.quantity / 10 numberPicker.value = event.quantity / 10
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = dateTimePicker(event.time, dateTV) val pickedTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
@@ -306,7 +304,7 @@ class MainActivity : AppCompatActivity() {
// Show number picker dialog // Show number picker dialog
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_weight, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_weight, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
@@ -314,7 +312,7 @@ class MainActivity : AppCompatActivity() {
weightET.setText(event.quantity.toString()) weightET.setText(event.quantity.toString())
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = dateTimePicker(event.time, dateTV) val pickedTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
@@ -350,7 +348,7 @@ class MainActivity : AppCompatActivity() {
// Show number picker dialog // Show number picker dialog
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_temperature, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_temperature, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
@@ -365,7 +363,7 @@ class MainActivity : AppCompatActivity() {
} }
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = dateTimePicker(event.time, dateTV) val pickedTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
} }
@@ -389,8 +387,7 @@ class MainActivity : AppCompatActivity() {
alertDialog.show() alertDialog.show()
} }
// Pick a date/time. fun datePickerHelper(time: Long, dateTextView: TextView, onChange: (Long) -> Unit = {}): Calendar {
fun dateTimePicker(time: Long, dateTextView: TextView, onChange: (Long) -> Unit = {}): Calendar {
dateTextView.text = DateUtils.formatDateTime(time) dateTextView.text = DateUtils.formatDateTime(time)
val dateTime = Calendar.getInstance() val dateTime = Calendar.getInstance()
@@ -421,83 +418,89 @@ class MainActivity : AppCompatActivity() {
return dateTime return dateTime
} }
fun addSleepEvent(event: LunaEvent) { fun saveEvent(event: LunaEvent) {
askSleepValue(event, true) { saveEvent(event) } if (!allEvents.contains(event)) {
// new event
logEvent(event)
}
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
} }
fun askSleepValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) { fun addSleepEvent(event: LunaEvent) {
askSleepValue(event) { saveEvent(event) }
}
fun askSleepValue(event: LunaEvent, onPositive: () -> Unit) {
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_duration, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_duration, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
val durationTextView = dialogView.findViewById<TextView>(R.id.dialog_date_duration) val durationTextView = dialogView.findViewById<TextView>(R.id.dialog_date_duration)
val datePickerBegin = dialogView.findViewById<TextView>(R.id.dialog_date_picker_begin) val durationNowButton = dialogView.findViewById<Button>(R.id.dialog_date_duration_now)
val datePickerEnd = dialogView.findViewById<TextView>(R.id.dialog_date_picker_end) val datePicker = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val durationMinus5Button = dialogView.findViewById<Button>(R.id.dialog_date_duration_minus5)
val durationPlus5Button = dialogView.findViewById<Button>(R.id.dialog_date_duration_plus5)
val currentDurationTextColor = durationTextView.currentTextColor val currentDurationTextColor = durationTextView.currentTextColor
val invalidDurationTextColor = ContextCompat.getColor(this, R.color.danger) val invalidDurationTextColor = ContextCompat.getColor(this, R.color.danger)
// in seconds var duration = event.quantity
var sleepStart = event.time
var sleepEnd = event.time + event.quantity
fun isValidTimeSpan(timeBeginUnix: Long, timeEndUnix: Long): Boolean { fun isValidTime(timeSeconds: Long, durationSeconds: Int): Boolean {
val now = System.currentTimeMillis() / 1000 val now = Calendar.getInstance().time.time / 1000
return (timeBeginUnix > 0) return (timeSeconds + durationSeconds) <= now && durationSeconds < (12 * 60 * 60)
&& (timeEndUnix > 0)
&& (timeBeginUnix <= timeEndUnix)
&& (timeBeginUnix <= now)
&& (timeEndUnix <= now)
&& (timeEndUnix - timeBeginUnix) < (24 * 60 * 60)
} }
// prevent printing of seconds val onDateChange = { time: Long ->
fun adjustToMinute(unixTime: Long): Long {
return unixTime - (unixTime % 60)
}
fun updateDuration() {
durationTextView.setTextColor(currentDurationTextColor) durationTextView.setTextColor(currentDurationTextColor)
val duration = sleepEnd - sleepStart
if (duration == 0L) { if (duration == 0) {
// baby is sleeping // baby is sleeping
durationTextView.text = "💤" durationTextView.text = "💤"
} else { } else {
durationTextView.text = DateUtils.formatTimeDuration(applicationContext, duration) durationTextView.text = DateUtils.formatTimeDuration(applicationContext, duration.toLong())
if (!isValidTimeSpan(sleepStart, sleepEnd)) { if (!isValidTime(time, duration)) {
durationTextView.setTextColor(invalidDurationTextColor) durationTextView.setTextColor(invalidDurationTextColor)
} }
} }
} }
val pickedDateTimeBegin = dateTimePicker(event.time, datePickerBegin) { time: Long -> val pickedDateTime = datePickerHelper(event.time, datePicker, onDateChange)
sleepStart = adjustToMinute(time)
updateDuration() onDateChange(pickedDateTime.time.time / 1000)
fun adjust(minutes: Int) {
duration += minutes * 60
if (duration < 0) {
duration = 0
}
onDateChange(pickedDateTime.time.time / 1000)
} }
val pickedDateTimeEnd = dateTimePicker(event.time + event.quantity, datePickerEnd) { time: Long -> durationMinus5Button.setOnClickListener { adjust(-5) }
sleepEnd = adjustToMinute(time) durationPlus5Button.setOnClickListener { adjust(5) }
updateDuration()
}
sleepStart = adjustToMinute(pickedDateTimeBegin.time.time / 1000) durationNowButton.setOnClickListener {
sleepEnd = adjustToMinute(pickedDateTimeEnd.time.time / 1000) val now = Calendar.getInstance().time.time / 1000
updateDuration() val start = pickedDateTime.time.time / 1000
if (now > start) {
if (showTime) { duration = (now - start).toInt()
datePickerEnd.visibility = View.GONE duration -= duration % 60 // prevent printing of seconds
durationTextView.visibility = View.GONE onDateChange(pickedDateTime.time.time / 1000)
//d.setMessage("") }
} else {
durationTextView.visibility = View.VISIBLE
d.setMessage(event.getDialogMessage(this))
} }
d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> d.setPositiveButton(android.R.string.ok) { dialogInterface, i ->
if (isValidTimeSpan(sleepStart, sleepEnd)) { val time = pickedDateTime.time.time / 1000
event.time = sleepStart
event.quantity = (sleepEnd - sleepStart).toInt() if (isValidTime(time, duration)) {
event.time = time
event.quantity = duration
onPositive() onPositive()
} else { } else {
Toast.makeText(this, R.string.toast_date_error, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.toast_date_error, Toast.LENGTH_SHORT).show()
@@ -514,14 +517,13 @@ class MainActivity : AppCompatActivity() {
} }
fun addAmountEvent(event: LunaEvent) { fun addAmountEvent(event: LunaEvent) {
setToPreviousQuantity(event)
askAmountValue(event, true) { saveEvent(event) } askAmountValue(event, true) { saveEvent(event) }
} }
fun askAmountValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) { fun askAmountValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) {
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_amount, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_amount, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
@@ -531,11 +533,11 @@ class MainActivity : AppCompatActivity() {
R.array.AmountLabels, R.array.AmountLabels,
android.R.layout.simple_spinner_dropdown_item android.R.layout.simple_spinner_dropdown_item
) )
// set pre-selected item and ensure the quantity to index is in bounds
spinner.setSelection(event.quantity.coerceIn(0, spinner.count - 1)) spinner.setSelection(event.quantity.coerceIn(0, spinner.count - 1))
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = dateTimePicker(event.time, dateTV) val pickedTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
} }
@@ -563,12 +565,12 @@ class MainActivity : AppCompatActivity() {
fun askDateValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) { fun askDateValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) {
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_plain, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_plain, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedDateTime = dateTimePicker(event.time, dateTV) val pickedDateTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
} }
@@ -596,14 +598,14 @@ class MainActivity : AppCompatActivity() {
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_notes, null) val dialogView = layoutInflater.inflate(R.layout.dialog_edit_notes, null)
d.setTitle(event.getDialogTitle(this)) d.setTitle(event.getTypeDescription(this))
d.setMessage(event.getDialogMessage(this)) d.setMessage(event.getDialogMessage(this))
d.setView(dialogView) d.setView(dialogView)
val notesET = dialogView.findViewById<EditText>(R.id.notes_edittext) val notesET = dialogView.findViewById<EditText>(R.id.notes_edittext)
val qtyET = dialogView.findViewById<EditText>(R.id.notes_qty_edittext) val qtyET = dialogView.findViewById<EditText>(R.id.notes_qty_edittext)
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker) val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = dateTimePicker(event.time, dateTV) val pickedTime = datePickerHelper(event.time, dateTV)
if (!showTime) { if (!showTime) {
dateTV.visibility = View.GONE dateTV.visibility = View.GONE
@@ -774,8 +776,8 @@ class MainActivity : AppCompatActivity() {
val quantityTextView = dialogView.findViewById<TextView>(R.id.dialog_event_detail_type_quantity) val quantityTextView = dialogView.findViewById<TextView>(R.id.dialog_event_detail_type_quantity)
val notesTextView = dialogView.findViewById<TextView>(R.id.dialog_event_detail_type_notes) val notesTextView = dialogView.findViewById<TextView>(R.id.dialog_event_detail_type_notes)
emojiTextView.text = event.getHeaderEmoji(this) emojiTextView.text = event.getTypeEmoji(this)
descriptionTextView.text = event.getDialogTitle(this) descriptionTextView.text = event.getTypeDescription(this)
d.setView(dialogView) d.setView(dialogView)
@@ -815,7 +817,7 @@ class MainActivity : AppCompatActivity() {
quantityTextView.text = NumericUtils(this).formatEventQuantity(event) quantityTextView.text = NumericUtils(this).formatEventQuantity(event)
notesTextView.text = event.notes notesTextView.text = event.notes
if (event.type == LunaEvent.Type.SLEEP && event.quantity > 0) { if (event.type == LunaEvent.Type.SLEEP && event.quantity > 0) {
dateEndTextView.text = DateUtils.formatDateTime(event.getEndTime()) dateEndTextView.text = DateUtils.formatDateTime(event.time + event.quantity)
dateEndTextView.visibility = View.VISIBLE dateEndTextView.visibility = View.VISIBLE
} else { } else {
dateEndTextView.visibility = View.GONE dateEndTextView.visibility = View.GONE
@@ -829,7 +831,7 @@ class MainActivity : AppCompatActivity() {
} }
updateValues() updateValues()
dateTimePicker(event.time, dateTextView, { newTime: Long -> datePickerHelper(event.time, dateTextView, { newTime: Long ->
event.time = newTime event.time = newTime
updateValues() updateValues()
}) })
@@ -843,7 +845,7 @@ class MainActivity : AppCompatActivity() {
LunaEvent.Type.PUKE -> askAmountValue(event, false, updateValues) LunaEvent.Type.PUKE -> askAmountValue(event, false, updateValues)
LunaEvent.Type.TEMPERATURE -> askTemperatureValue(event, false, updateValues) LunaEvent.Type.TEMPERATURE -> askTemperatureValue(event, false, updateValues)
LunaEvent.Type.NOTE -> askNotes(event, false, updateValues) LunaEvent.Type.NOTE -> askNotes(event, false, updateValues)
LunaEvent.Type.SLEEP -> askSleepValue(event, false, updateValues) LunaEvent.Type.SLEEP -> askSleepValue(event, updateValues)
else -> { else -> {
Log.w(TAG, "Unexpected type: ${event.type}") Log.w(TAG, "Unexpected type: ${event.type}")
} }
@@ -872,8 +874,8 @@ class MainActivity : AppCompatActivity() {
val previousTextView = dialogView.findViewById<TextView>(R.id.dialog_event_previous) val previousTextView = dialogView.findViewById<TextView>(R.id.dialog_event_previous)
val previousEvent = getPreviousSameEvent(event, allEvents) val previousEvent = getPreviousSameEvent(event, allEvents)
if (previousEvent != null) { if (previousEvent != null) {
val emoji = previousEvent.getHeaderEmoji(applicationContext) val emoji = previousEvent.getTypeEmoji(applicationContext)
val time = DateUtils.formatTimeDuration(applicationContext, event.getStartTime() - previousEvent.getEndTime()) val time = DateUtils.formatTimeDuration(applicationContext, event.time - previousEvent.time)
previousTextView.text = String.format("⬅️ %s %s", emoji, time) previousTextView.text = String.format("⬅️ %s %s", emoji, time)
previousTextView.setOnClickListener { previousTextView.setOnClickListener {
alertDialog.cancel() alertDialog.cancel()
@@ -887,8 +889,8 @@ class MainActivity : AppCompatActivity() {
val nextTextView = dialogView.findViewById<TextView>(R.id.dialog_event_next) val nextTextView = dialogView.findViewById<TextView>(R.id.dialog_event_next)
val nextEvent = getNextSameEvent(event, allEvents) val nextEvent = getNextSameEvent(event, allEvents)
if (nextEvent != null) { if (nextEvent != null) {
val emoji = nextEvent.getHeaderEmoji(applicationContext) val emoji = nextEvent.getTypeEmoji(applicationContext)
val time = DateUtils.formatTimeDuration(applicationContext, nextEvent.getStartTime() - event.getEndTime()) val time = DateUtils.formatTimeDuration(applicationContext, nextEvent.time - event.time)
nextTextView.text = String.format("%s %s ➡️", time, emoji) nextTextView.text = String.format("%s %s ➡️", time, emoji)
nextTextView.setOnClickListener { nextTextView.setOnClickListener {
alertDialog.cancel() alertDialog.cancel()
@@ -1058,7 +1060,7 @@ class MainActivity : AppCompatActivity() {
if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) { if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) {
for (e in logbook?.logs ?: listOf()) { for (e in logbook?.logs ?: listOf()) {
val em = e.getHeaderEmoji(this@MainActivity) val em = e.getTypeEmoji(this@MainActivity)
if (em == getString(R.string.event_unknown_type)) { if (em == getString(R.string.event_unknown_type)) {
Log.e(TAG, "UNKNOWN: ${e.type}") Log.e(TAG, "UNKNOWN: ${e.type}")
} }
@@ -1113,6 +1115,23 @@ class MainActivity : AppCompatActivity() {
}) })
} }
fun logEvent(event: LunaEvent) {
savingEvent(true)
event.signature = signature
setLoading(true)
logbook?.logs?.add(0, event)
recyclerView.adapter?.notifyItemInserted(0)
recyclerView.smoothScrollToPosition(0)
saveLogbook(event)
// Check logbook size to avoid OOM errors
if (logbook?.isTooBig() == true) {
askToTrimLogbook()
}
}
fun deleteEvent(event: LunaEvent) { fun deleteEvent(event: LunaEvent) {
// Update view // Update view
savingEvent(true) savingEvent(true)
@@ -1124,32 +1143,6 @@ class MainActivity : AppCompatActivity() {
saveLogbook() saveLogbook()
} }
fun saveEvent(event: LunaEvent) {
if (allEvents.contains(event)) {
// event was modified
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
} else {
// add new event
savingEvent(true)
setLoading(true)
if (signature.isNotEmpty()) {
event.signature = signature
}
logbook?.logs?.add(0, event)
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
recyclerView.smoothScrollToPosition(0)
saveLogbook(event)
// Check logbook size to avoid OOM errors
if (logbook?.isTooBig() == true) {
askToTrimLogbook()
}
}
}
/** /**
* Saves the logbook. If saving while adding an event, please specify the event so in case * Saves the logbook. If saving while adding an event, please specify the event so in case
* of error can be removed from the list. * of error can be removed from the list.
@@ -1284,11 +1277,23 @@ class MainActivity : AppCompatActivity() {
val inflater = LayoutInflater.from(anchor.context) val inflater = LayoutInflater.from(anchor.context)
contentView = inflater.inflate(R.layout.more_events_popup, null) contentView = inflater.inflate(R.layout.more_events_popup, null)
// Add statistics (hard coded)
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()
}
val linearLayout = contentView.findViewById<LinearLayout>(R.id.layout_list) val linearLayout = contentView.findViewById<LinearLayout>(R.id.layout_list)
// Add buttons to create other events // Add buttons to create other events
for (type in currentPopupItems) { for (type in currentPopupItems) {
val view = layoutInflater.inflate(R.layout.more_events_popup_item, linearLayout, false) val view = inflater.inflate(R.layout.more_events_popup_item, null)
val textView = view.findViewById<TextView>(R.id.tv) val textView = view.findViewById<TextView>(R.id.tv)
textView.text = LunaEvent.getPopupItemTitle(applicationContext, type) textView.text = LunaEvent.getPopupItemTitle(applicationContext, type)
textView.setOnClickListener { textView.setOnClickListener {

View File

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

View File

@@ -52,13 +52,18 @@ class LunaEventRecyclerAdapter: RecyclerView.Adapter<LunaEventRecyclerAdapter.Lu
) )
holder.quantity.setTextColor(ContextCompat.getColor(context, R.color.textColor)) holder.quantity.setTextColor(ContextCompat.getColor(context, R.color.textColor))
// Contents // Contents
holder.type.text = item.getHeaderEmoji(context) holder.type.text = item.getTypeEmoji(context)
holder.description.text = when (item.type) { holder.description.text = when (item.type) {
LunaEvent.Type.MEDICINE -> item.notes LunaEvent.Type.MEDICINE -> item.notes
LunaEvent.Type.NOTE -> item.notes LunaEvent.Type.NOTE -> item.notes
else -> item.getRowItemTitle(context) else -> item.getTypeDescription(context)
} }
holder.time.text = DateUtils.formatTimeAgo(context, item.getEndTime()) val endTime = if (item.type == LunaEvent.Type.SLEEP) {
item.quantity + item.time
} else {
item.time
}
holder.time.text = DateUtils.formatTimeAgo(context, endTime)
var quantityText = numericUtils.formatEventQuantity(item) var quantityText = numericUtils.formatEventQuantity(item)
// if the event is weight, show difference with the last one // if the event is weight, show difference with the last one

View File

@@ -12,6 +12,7 @@ import java.util.Date
* release, it is simply ignored by previous ones). * release, it is simply ignored by previous ones).
*/ */
class LunaEvent: Comparable<LunaEvent> { class LunaEvent: Comparable<LunaEvent> {
enum class Type { enum class Type {
BABY_BOTTLE, BABY_BOTTLE,
FOOD, FOOD,
@@ -99,34 +100,18 @@ class LunaEvent: Comparable<LunaEvent> {
this.quantity = quantity this.quantity = quantity
} }
fun getHeaderEmoji(context: Context): String { fun getTypeEmoji(context: Context): String {
return getHeaderEmoji(context, type) return getTypeEmoji(context, type)
} }
fun getDialogTitle(context: Context): String { fun getTypeDescription(context: Context): String {
return getDialogTitle(context, type) return getTypeDescription(context, type)
}
fun getRowItemTitle(context: Context): String {
return getPopupItemTitle(context, type).split(" ", limit = 2).last() // remove emoji
} }
fun getDialogMessage(context: Context): String { fun getDialogMessage(context: Context): String {
return getDialogMessage(context, type) return getDialogMessage(context, type)
} }
fun getStartTime(): Long {
return time
}
fun getEndTime(): Long {
return if (type == Type.SLEEP) {
time + quantity
} else {
time
}
}
fun toJson(): JSONObject { fun toJson(): JSONObject {
return jo return jo
} }
@@ -140,7 +125,7 @@ class LunaEvent: Comparable<LunaEvent> {
} }
companion object { companion object {
fun getHeaderEmoji(context: Context, type: Type): String { fun getTypeEmoji(context: Context, type: Type): String {
return context.getString( return context.getString(
when (type) { when (type) {
Type.BABY_BOTTLE -> R.string.event_bottle_type Type.BABY_BOTTLE -> R.string.event_bottle_type
@@ -180,7 +165,7 @@ class LunaEvent: Comparable<LunaEvent> {
) )
} }
fun getDialogTitle(context: Context, type: Type): String { fun getTypeDescription(context: Context, type: Type): String {
return context.getString( return context.getString(
when (type) { when (type) {
Type.BABY_BOTTLE -> R.string.event_bottle_desc Type.BABY_BOTTLE -> R.string.event_bottle_desc

View File

@@ -56,11 +56,11 @@ class DateUtils {
return builder.toString() return builder.toString()
} }
if (years != 0L) { if (years > 0) {
return format(years, days, R.string.year_ago, R.string.years_ago, R.string.day_ago, R.string.days_ago) return format(years, days, R.string.year_ago, R.string.years_ago, R.string.day_ago, R.string.days_ago)
} else if (days != 0L) { } else if (days > 0) {
return format(days, hours, R.string.day_ago, R.string.days_ago, R.string.hour_ago, R.string.hours_ago) return format(days, hours, R.string.day_ago, R.string.days_ago, R.string.hour_ago, R.string.hours_ago)
} else if (hours != 0L) { } else if (hours > 0) {
return format(hours, minutes, R.string.hour_ago, R.string.hours_ago, R.string.minute_ago, R.string.minutes_ago) return format(hours, minutes, R.string.hour_ago, R.string.hours_ago, R.string.minute_ago, R.string.minutes_ago)
} else { } else {
return format(minutes, seconds, R.string.minute_ago, R.string.minute_ago, R.string.second_ago, R.string.seconds_ago) return format(minutes, seconds, R.string.minute_ago, R.string.minute_ago, R.string.second_ago, R.string.seconds_ago)

View File

@@ -101,7 +101,7 @@ class NumericUtils (val context: Context) {
else -> "" else -> ""
}) })
} }
return formatted.toString().trim() return formatted.toString()
} }
/** /**

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -177,16 +177,15 @@
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:textSize="30sp"/> android:textSize="30sp"/>
<TextView <ImageView
android:id="@+id/button_more" android:id="@+id/button_more"
android:layout_width="0dp" android:layout_width="60dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_margin="5dp" android:layout_margin="5dp"
android:layout_weight="1" android:layout_weight="0"
android:background="@drawable/button_background" android:background="@drawable/button_background"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:textSize="30sp" android:src="@drawable/ic_more"
android:text="☰"
app:tint="@android:color/darker_gray"/> app:tint="@android:color/darker_gray"/>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,57 @@
<?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:orientation="horizontal">
<Spinner
android:id="@+id/type_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1" />
<!--
<Spinner
android:id="@+id/data_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1"/>
-->
<Spinner
android:id="@+id/time_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1"/>
</LinearLayout>
</LinearLayout>

View File

@@ -7,23 +7,46 @@
android:gravity="center" android:gravity="center"
android:orientation="vertical"> android:orientation="vertical">
<TextView
android:id="@+id/dialog_date_picker_begin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="10dp"/>
<TextView <TextView
android:id="@+id/dialog_date_duration" android:id="@+id/dialog_date_duration"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:textSize="20sp" android:textSize="20sp"
android:text="💤"/> android:text="💤"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginTop="20dp"
android:layout_marginHorizontal="10dp"
android:orientation="horizontal">
<Button
android:id="@+id/dialog_date_duration_minus5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-5"/>
<Button
android:id="@+id/dialog_date_duration_now"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/now"/>
<Button
android:id="@+id/dialog_date_duration_plus5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="+5"/>
</LinearLayout>
<TextView <TextView
android:id="@+id/dialog_date_picker_end" android:id="@+id/dialog_date_picker"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"

View File

@@ -10,7 +10,18 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<!-- Buttons are inserted dynamically -->
<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"/>
<!-- Other buttons are inserted dynamically -->
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tv"
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="Item Template"/>

View File

@@ -18,6 +18,13 @@
<string name="event_colic_desc">Blähungskolik</string> <string name="event_colic_desc">Blähungskolik</string>
<string name="event_unknown_desc"></string> <string name="event_unknown_desc"></string>
<string name="overflow_event_weight">⚖️ Gewicht</string>
<string name="overflow_event_medicine">💊 Medikament</string>
<string name="overflow_event_enema">🪠 Einlauf</string>
<string name="overflow_event_note">📝 Notiz</string>
<string name="overflow_event_temperature">🌡️ Temperatur</string>
<string name="overflow_event_colic">💨 Blähungskolik</string>
<string name="toast_event_added">Ereignis gespeichert</string> <string name="toast_event_added">Ereignis gespeichert</string>
<string name="toast_logbook_saved">Logbuch gespeichert</string> <string name="toast_logbook_saved">Logbuch gespeichert</string>
<string name="toast_event_add_error">Ereignis konnte nicht protokolliert werden</string> <string name="toast_event_add_error">Ereignis konnte nicht protokolliert werden</string>
@@ -35,6 +42,7 @@
<string name="no_connection_retry">Erneut versuchen</string> <string name="no_connection_retry">Erneut versuchen</string>
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
<string name="settings_no_breastfeeding">Kein Stillen</string>
<string name="settings_storage">Speicherort für Daten auswählen</string> <string name="settings_storage">Speicherort für Daten auswählen</string>
<string name="settings_storage_local">Auf dem Gerät</string> <string name="settings_storage_local">Auf dem Gerät</string>
<string name="settings_storage_local_desc">Datenschutzfreundlichste Lösung: Deine Daten verlassen dein Gerät nicht</string> <string name="settings_storage_local_desc">Datenschutzfreundlichste Lösung: Deine Daten verlassen dein Gerät nicht</string>

View File

@@ -18,6 +18,13 @@
<string name="event_colic_desc">Colique gazeuse</string> <string name="event_colic_desc">Colique gazeuse</string>
<string name="event_unknown_desc"></string> <string name="event_unknown_desc"></string>
<string name="overflow_event_weight">⚖️ Poids</string>
<string name="overflow_event_medicine">💊 Médicament</string>
<string name="overflow_event_enema">🪠 Lavement</string>
<string name="overflow_event_note">📝 Note</string>
<string name="overflow_event_temperature">🌡️ Température</string>
<string name="overflow_event_colic">💨 Colique gazeuse</string>
<string name="toast_event_added">Entrée ajoutée</string> <string name="toast_event_added">Entrée ajoutée</string>
<string name="toast_logbook_saved">Journal ajouté</string> <string name="toast_logbook_saved">Journal ajouté</string>
<string name="toast_event_add_error">Impossible d\'enregistrer cette entrée</string> <string name="toast_event_add_error">Impossible d\'enregistrer cette entrée</string>

View File

@@ -3,6 +3,13 @@
<string name="title">🌜 LunaTracker 🌛</string> <string name="title">🌜 LunaTracker 🌛</string>
<string name="logbook">Diario di bordo</string> <string name="logbook">Diario di bordo</string>
<string name="overflow_event_weight">⚖️ Peso</string>
<string name="overflow_event_medicine">💊 Medicina</string>
<string name="overflow_event_enema">🪠 Clistere</string>
<string name="overflow_event_note">📝 Nota</string>
<string name="overflow_event_temperature">🌡️ Temperatura</string>
<string name="overflow_event_colic">💨 Colichette</string>
<string name="event_bottle_desc">Biberon</string> <string name="event_bottle_desc">Biberon</string>
<string name="event_food_desc">Cibo</string> <string name="event_food_desc">Cibo</string>
<string name="event_weight_desc">Pesata</string> <string name="event_weight_desc">Pesata</string>

View File

@@ -6,4 +6,45 @@
<item>@string/amount_normal</item> <item>@string/amount_normal</item>
<item>@string/amount_plenty</item> <item>@string/amount_plenty</item>
</string-array> </string-array>
<string-array name="StatisticsTypeLabels">
<item>BOTTLE_EVENTS</item>
<item>BOTTLE_SUM</item>
<item>BOTTLE_SUM_AVERAGE</item>
<item>SLEEP_SUM_AVERAGE</item>
<item>SLEEP_EVENTS</item>
<item>SLEEP_PATTERN</item>
</string-array>
<string-array name="StatisticsTypeValues">
<item>BOTTLE_EVENTS</item>
<item>BOTTLE_SUM</item>
<item>BOTTLE_SUM_AVERAGE</item>
<item>SLEEP_SUM_AVERAGE</item>
<item>SLEEP_EVENTS</item>
<item>SLEEP_PATTERN</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>
</resources> </resources>

View File

@@ -29,8 +29,8 @@
<string name="event_type_item_breastfeeding_left">🤱⬅️ Nursing</string> <string name="event_type_item_breastfeeding_left">🤱⬅️ Nursing</string>
<string name="event_type_item_breastfeeding_both">🤱↔️ Nursing</string> <string name="event_type_item_breastfeeding_both">🤱↔️ Nursing</string>
<string name="event_type_item_breastfeeding_right">🤱➡️️ Nursing</string> <string name="event_type_item_breastfeeding_right">🤱➡️️ Nursing</string>
<string name="event_type_item_diaperchange_poo">🚼💩 Diaper</string> <string name="event_type_item_diaperchange_poo">💩 Diaper</string>
<string name="event_type_item_diaperchange_pee">🚼💧 Diaper</string> <string name="event_type_item_diaperchange_pee">💧 Diaper</string>
<string name="event_type_item_medicine">💊 Medicine</string> <string name="event_type_item_medicine">💊 Medicine</string>
<string name="event_type_item_enema">🪠 Enema</string> <string name="event_type_item_enema">🪠 Enema</string>
<string name="event_type_item_note">📝 Note</string> <string name="event_type_item_note">📝 Note</string>
@@ -42,7 +42,7 @@
<string name="event_type_item_unknown">❓ Unknown</string> <string name="event_type_item_unknown">❓ Unknown</string>
<!-- dialog titles --> <!-- dialog titles -->
<string name="event_bottle_desc">Milk Bottle</string> <string name="event_bottle_desc">Bottle</string>
<string name="event_food_desc">Food</string> <string name="event_food_desc">Food</string>
<string name="event_weight_desc">Weight</string> <string name="event_weight_desc">Weight</string>
<string name="event_breastfeeding_left_desc">Nursing (left)</string> <string name="event_breastfeeding_left_desc">Nursing (left)</string>
@@ -58,7 +58,7 @@
<string name="event_puke_desc">Puke</string> <string name="event_puke_desc">Puke</string>
<string name="event_bath_desc">Bath</string> <string name="event_bath_desc">Bath</string>
<string name="event_sleep_desc">Sleep</string> <string name="event_sleep_desc">Sleep</string>
<string name="event_unknown_desc">Unknown</string> <string name="event_unknown_desc"></string>
<string name="toast_event_added">Event logged</string> <string name="toast_event_added">Event logged</string>
<string name="toast_logbook_saved">Logbook saved</string> <string name="toast_logbook_saved">Logbook saved</string>
@@ -88,6 +88,8 @@
<string name="no_connection_go_to_settings">Settings</string> <string name="no_connection_go_to_settings">Settings</string>
<string name="no_connection_retry">Retry</string> <string name="no_connection_retry">Retry</string>
<string name="statistics_title">Statistics</string>
<string name="settings_dynamic_menu">Dynamic Menu</string> <string name="settings_dynamic_menu">Dynamic Menu</string>
<string name="settings_dynamic_menu_desc">Populate the header menu with the most used events.</string> <string name="settings_dynamic_menu_desc">Populate the header menu with the most used events.</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>

View File

@@ -9,6 +9,8 @@ lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
composeBom = "2024.04.01" composeBom = "2024.04.01"
appcompat = "1.7.0" appcompat = "1.7.0"
mpandroidchart = "v4.2.2"
mpandroidchartVersion = "v3.1.0"
recyclerview = "1.3.2" recyclerview = "1.3.2"
material = "1.12.0" material = "1.12.0"
sardineAndroid = "v0.9" sardineAndroid = "v0.9"
@@ -31,6 +33,8 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } 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" }
sardine-android = { module = "com.github.thegrizzlylabs:sardine-android", version.ref = "sardineAndroid" } sardine-android = { module = "com.github.thegrizzlylabs:sardine-android", version.ref = "sardineAndroid" }
[plugins] [plugins]