6 Commits

Author SHA1 Message Date
a1f3c7fdea MainActivity: generate dynamic menu from last two weeks 2026-02-19 10:36:01 +01:00
bba8ccd1c9 LunaEvent: rework sleep event
Make the UI more flexible and
slightly easier to understand.
2026-02-19 10:36:01 +01:00
d9d1b8cb83 MainActivity: rename datepicker 2026-02-19 10:36:01 +01:00
8b56d92ece layout: replace menu icon with utf8 character
The three dot menu icosn looks odd when stretched
due to the dynamic menu feature. Thus replace it
with the hamburger menu character that looks better
when scaled.
2026-02-19 10:36:01 +01:00
25ac67a45d MainActivity: sort events before saving
Also replace notifyItemInserted since it does not
call the adapter to redraw the row striping when
a new event is added.
2026-02-19 10:36:01 +01:00
8553e3cd7f StatisticsActivity: rework all statistics
Improve the overall code.
2026-02-19 10:35:57 +01:00
11 changed files with 950 additions and 312 deletions

View File

@@ -148,10 +148,12 @@ class MainActivity : AppCompatActivity() {
val eventTypeStats = mutableMapOf<LunaEvent.Type, Int>()
if (dynamicMenu) {
val sampleSize = 100
// populate frequency map from first 100 events
allEvents.take(sampleSize.coerceAtMost(allEvents.size)).forEach {
eventTypeStats[it.type] = 1 + (eventTypeStats[it.type] ?: 0)
// populate frequency map from all events of the last two weeks
val lastWeekTime = (System.currentTimeMillis() / 1000) - (14 * 24 * 60 * 60)
allEvents.forEach {
if (it.time > lastWeekTime) {
eventTypeStats[it.type] = 1 + (eventTypeStats[it.type] ?: 0)
}
}
}
@@ -276,7 +278,7 @@ class MainActivity : AppCompatActivity() {
numberPicker.value = event.quantity / 10
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = datePickerHelper(event.time, dateTV)
val pickedTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
@@ -314,7 +316,7 @@ class MainActivity : AppCompatActivity() {
weightET.setText(event.quantity.toString())
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = datePickerHelper(event.time, dateTV)
val pickedTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
@@ -365,7 +367,7 @@ class MainActivity : AppCompatActivity() {
}
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = datePickerHelper(event.time, dateTV)
val pickedTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
}
@@ -389,7 +391,8 @@ class MainActivity : AppCompatActivity() {
alertDialog.show()
}
fun datePickerHelper(time: Long, dateTextView: TextView, onChange: (Long) -> Unit = {}): Calendar {
// Pick a date/time.
fun dateTimePicker(time: Long, dateTextView: TextView, onChange: (Long) -> Unit = {}): Calendar {
dateTextView.text = DateUtils.formatDateTime(time)
val dateTime = Calendar.getInstance()
@@ -420,97 +423,121 @@ class MainActivity : AppCompatActivity() {
return dateTime
}
fun saveEvent(event: LunaEvent) {
if (!allEvents.contains(event)) {
// new event
logEvent(event)
}
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
fun addSleepEvent(event: LunaEvent) {
askSleepValue(event, true) { saveEvent(event) }
}
fun askSleepValue(event: LunaEvent, hideDurationButtons: Boolean, onPositive: () -> Unit) {
fun askSleepValue(event: LunaEvent, showTime: Boolean, onPositive: () -> Unit) {
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_duration, null)
d.setTitle(event.getDialogTitle(this))
d.setMessage(event.getDialogMessage(this))
d.setView(dialogView)
val durationTextView = dialogView.findViewById<TextView>(R.id.dialog_date_duration)
val datePicker = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val datePickerBegin = dialogView.findViewById<TextView>(R.id.dialog_date_picker_begin)
val datePickerEnd = dialogView.findViewById<TextView>(R.id.dialog_date_picker_end)
val dateDelimiter = dialogView.findViewById<TextView>(R.id.dialog_date_range_delimiter)
val durationButtons = dialogView.findViewById<LinearLayout>(R.id.duration_buttons)
val durationNowButton = dialogView.findViewById<Button>(R.id.dialog_date_duration_now)
val durationAsleepButton = dialogView.findViewById<Button>(R.id.dialog_date_duration_asleep)
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 invalidDurationTextColor = ContextCompat.getColor(this, R.color.danger)
var duration = event.quantity
// in seconds
var sleepBegin = event.time
var sleepEnd = event.time + event.quantity
fun isValidTime(timeSeconds: Long, durationSeconds: Int): Boolean {
fun isValidTime(timeUnix: Long): Boolean {
val now = System.currentTimeMillis() / 1000
return (timeSeconds + durationSeconds) <= now && durationSeconds < (24 * 60 * 60)
return timeUnix in 1..now
}
val onDateChange = { time: Long ->
durationTextView.setTextColor(currentDurationTextColor)
fun isValidTimeSpan(timeBeginUnix: Long, timeEndUnix: Long): Boolean {
return (timeBeginUnix <= timeEndUnix) && (timeEndUnix - timeBeginUnix) < (24 * 60 * 60)
}
if (duration == 0) {
// prevent printing of seconds
fun adjustToMinute(unixTime: Long): Long {
return unixTime - (unixTime % 60)
}
fun updateFields() {
datePickerBegin.text = DateUtils.formatDateTime(sleepBegin)
datePickerEnd.text = DateUtils.formatDateTime(sleepEnd)
durationTextView.setTextColor(currentDurationTextColor)
val duration = sleepEnd - sleepBegin
if (duration == 0L) {
// baby is sleeping
durationTextView.text = "💤"
} else {
durationTextView.text = DateUtils.formatTimeDuration(applicationContext, duration.toLong())
if (!isValidTime(time, duration)) {
durationTextView.text = DateUtils.formatTimeDuration(applicationContext, duration)
if (!isValidTimeSpan(sleepBegin, sleepEnd)) {
durationTextView.setTextColor(invalidDurationTextColor)
}
}
datePickerBegin.setTextColor(if (isValidTime(sleepBegin)) { currentDurationTextColor } else { invalidDurationTextColor })
datePickerEnd.setTextColor(if (isValidTime(sleepEnd)) { currentDurationTextColor } else { invalidDurationTextColor })
}
val pickedDateTime = datePickerHelper(event.time, datePicker, onDateChange)
val pickedDateTimeBegin = dateTimePicker(event.time, datePickerBegin) { time: Long ->
sleepBegin = adjustToMinute(time)
updateFields()
}
onDateChange(pickedDateTime.time.time / 1000)
val pickedDateTimeEnd = dateTimePicker(event.time + event.quantity, datePickerEnd) { time: Long ->
sleepEnd = adjustToMinute(time)
updateFields()
}
if (hideDurationButtons) {
sleepBegin = adjustToMinute(pickedDateTimeBegin.time.time / 1000)
sleepEnd = adjustToMinute(pickedDateTimeEnd.time.time / 1000)
updateFields()
if (showTime) {
dateDelimiter.visibility = View.GONE
datePickerEnd.visibility = View.GONE
durationTextView.visibility = View.GONE
durationButtons.visibility = View.GONE
d.setMessage(getString(R.string.log_sleep_dialog_description_start))
//d.setMessage("")
} else {
dateDelimiter.visibility = View.VISIBLE
datePickerEnd.visibility = View.VISIBLE
durationTextView.visibility = View.VISIBLE
durationButtons.visibility = View.VISIBLE
d.setMessage(event.getDialogMessage(this))
}
fun adjust(minutes: Int) {
duration += minutes * 60
if (duration < 0) {
duration = 0
}
onDateChange(pickedDateTime.time.time / 1000)
}
durationMinus5Button.setOnClickListener {
sleepEnd = (sleepEnd - 300).coerceAtLeast(sleepBegin)
updateFields()
}
durationMinus5Button.setOnClickListener { adjust(-5) }
durationPlus5Button.setOnClickListener { adjust(5) }
durationPlus5Button.setOnClickListener {
sleepEnd = (sleepEnd + 300).coerceAtLeast(sleepBegin)
updateFields()
}
durationNowButton.setOnClickListener {
val now = System.currentTimeMillis() / 1000
val start = pickedDateTime.time.time / 1000
if (now > start) {
duration = (now - start).toInt()
duration -= duration % 60 // prevent printing of seconds
onDateChange(pickedDateTime.time.time / 1000)
}
}
durationAsleepButton.setOnClickListener {
sleepEnd = sleepBegin
updateFields()
}
durationNowButton.setOnClickListener {
val now = System.currentTimeMillis() / 1000
sleepEnd = adjustToMinute(now)
updateFields()
}
d.setPositiveButton(android.R.string.ok) { dialogInterface, i ->
val time = pickedDateTime.time.time / 1000
if (isValidTime(time, duration)) {
event.time = time
event.quantity = duration
if (isValidTime(sleepBegin) && isValidTime(sleepEnd) && isValidTimeSpan(sleepBegin, sleepEnd)) {
event.time = sleepBegin
event.quantity = (sleepEnd - sleepBegin).toInt()
onPositive()
} else {
Toast.makeText(this, R.string.toast_date_error, Toast.LENGTH_SHORT).show()
@@ -548,7 +575,7 @@ class MainActivity : AppCompatActivity() {
spinner.setSelection(event.quantity.coerceIn(0, spinner.count - 1))
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = datePickerHelper(event.time, dateTV)
val pickedTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
}
@@ -581,7 +608,7 @@ class MainActivity : AppCompatActivity() {
d.setView(dialogView)
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedDateTime = datePickerHelper(event.time, dateTV)
val pickedDateTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
}
@@ -616,7 +643,7 @@ class MainActivity : AppCompatActivity() {
val qtyET = dialogView.findViewById<EditText>(R.id.notes_qty_edittext)
val dateTV = dialogView.findViewById<TextView>(R.id.dialog_date_picker)
val pickedTime = datePickerHelper(event.time, dateTV)
val pickedTime = dateTimePicker(event.time, dateTV)
if (!showTime) {
dateTV.visibility = View.GONE
@@ -828,7 +855,7 @@ class MainActivity : AppCompatActivity() {
quantityTextView.text = NumericUtils(this).formatEventQuantity(event)
notesTextView.text = event.notes
if (event.type == LunaEvent.Type.SLEEP && event.quantity > 0) {
dateEndTextView.text = DateUtils.formatDateTime(event.time + event.quantity)
dateEndTextView.text = DateUtils.formatDateTime(event.getEndTime())
dateEndTextView.visibility = View.VISIBLE
} else {
dateEndTextView.visibility = View.GONE
@@ -842,7 +869,7 @@ class MainActivity : AppCompatActivity() {
}
updateValues()
datePickerHelper(event.time, dateTextView, { newTime: Long ->
dateTimePicker(event.time, dateTextView, { newTime: Long ->
event.time = newTime
updateValues()
})
@@ -886,7 +913,7 @@ class MainActivity : AppCompatActivity() {
val previousEvent = getPreviousSameEvent(event, allEvents)
if (previousEvent != null) {
val emoji = previousEvent.getHeaderEmoji(applicationContext)
val time = DateUtils.formatTimeDuration(applicationContext, event.time - previousEvent.time)
val time = DateUtils.formatTimeDuration(applicationContext, event.getStartTime() - previousEvent.getEndTime())
previousTextView.text = String.format("⬅️ %s %s", emoji, time)
previousTextView.setOnClickListener {
alertDialog.cancel()
@@ -901,7 +928,7 @@ class MainActivity : AppCompatActivity() {
val nextEvent = getNextSameEvent(event, allEvents)
if (nextEvent != null) {
val emoji = nextEvent.getHeaderEmoji(applicationContext)
val time = DateUtils.formatTimeDuration(applicationContext, nextEvent.time - event.time)
val time = DateUtils.formatTimeDuration(applicationContext, nextEvent.getStartTime() - event.getEndTime())
nextTextView.text = String.format("%s %s ➡️", time, emoji)
nextTextView.setOnClickListener {
alertDialog.cancel()
@@ -1126,23 +1153,6 @@ 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) {
// Update view
savingEvent(true)
@@ -1154,6 +1164,32 @@ class MainActivity : AppCompatActivity() {
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
* of error can be removed from the list.

View File

@@ -58,12 +58,7 @@ class LunaEventRecyclerAdapter: RecyclerView.Adapter<LunaEventRecyclerAdapter.Lu
LunaEvent.Type.NOTE -> item.notes
else -> item.getRowItemTitle(context)
}
val endTime = if (item.type == LunaEvent.Type.SLEEP) {
item.quantity + item.time
} else {
item.time
}
holder.time.text = DateUtils.formatTimeAgo(context, endTime)
holder.time.text = DateUtils.formatTimeAgo(context, item.getEndTime())
var quantityText = numericUtils.formatEventQuantity(item)
// if the event is weight, show difference with the last one

View File

@@ -115,6 +115,18 @@ class LunaEvent: Comparable<LunaEvent> {
return getDialogMessage(context, type)
}
fun getStartTime(): Long {
return time
}
fun getEndTime(): Long {
return if (type == Type.SLEEP) {
time + quantity
} else {
time
}
}
fun toJson(): JSONObject {
return jo
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
android:layout_height="match_parent"
android:visibility="gone"
android:gravity="center"
android:text="No Data"/>
android:text="@string/statistics_no_data"/>
</FrameLayout>
@@ -32,25 +32,18 @@
android:orientation="horizontal">
<Spinner
android:id="@+id/type_selection"
android:id="@+id/graph_type_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1" />
android:layout_weight="1"
android:gravity="center"/>
<Spinner
android:id="@+id/data_selection"
android:id="@+id/time_range_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"/>
android:layout_weight="1"
android:gravity="center"/>
</LinearLayout>

View File

@@ -11,13 +11,14 @@
android:id="@+id/dialog_date_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textSize="20sp"
android:text="💤"/>
<LinearLayout
android:id="@+id/duration_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:gravity="center"
android:layout_marginTop="20dp"
android:layout_marginHorizontal="10dp"
@@ -28,29 +29,61 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-5"/>
android:text="@string/dialog_duration_button_minus_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"/>
android:text="@string/dialog_duration_button_now"/>
<Button
android:id="@+id/dialog_date_duration_asleep"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/dialog_duration_button_asleep"/>
<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"/>
android:text="@string/dialog_duration_button_plus_5"/>
</LinearLayout>
<TextView
android:id="@+id/dialog_date_picker"
android:layout_width="wrap_content"
<LinearLayout
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"/>
android:layout_width="match_parent"
android:gravity="center"
android:layout_marginTop="20dp"
android:layout_marginHorizontal="10dp"
android:orientation="horizontal">
<TextView
android:id="@+id/dialog_date_picker_begin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1"/>
<TextView
android:id="@+id/dialog_date_range_delimiter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1"
android:text=""/>
<TextView
android:id="@+id/dialog_date_picker_end"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_weight="1"/>
</LinearLayout>
</LinearLayout>

View File

@@ -8,23 +8,19 @@
</string-array>
<string-array name="StatisticsTypeLabels">
<item>Bottle</item>
<item>Sleep</item>
<item>@string/statistics_bottle_sum</item>
<item>@string/statistics_bottle_events</item>
<item>@string/statistics_sleep_sum</item>
<item>@string/statistics_sleep_events</item>
<item>@string/statistics_sleep_pattern</item>
</string-array>
<string-array name="StatisticsTypeValues">
<item>BOTTLE</item>
<item>SLEEP</item>
</string-array>
<string-array name="StatisticsDataLabels">
<item>Event</item>
<item>Amount</item>
</string-array>
<string-array name="StatisticsDataValues">
<item>EVENT</item>
<item>AMOUNT</item>
<item>BOTTLE_SUM</item>
<item>BOTTLE_EVENTS</item>
<item>SLEEP_SUM</item>
<item>SLEEP_EVENTS</item>
<item>SLEEP_PATTERN</item>
</string-array>
<string-array name="StatisticsTimeLabels">
@@ -38,5 +34,4 @@
<item>WEEK</item>
<item>MONTH</item>
</string-array>
</resources>

View File

@@ -89,6 +89,7 @@
<string name="no_connection_retry">Retry</string>
<string name="statistics_title">Statistics</string>
<string name="statistics_no_data">No Data</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>
@@ -130,7 +131,6 @@
<string name="log_unknown_dialog_description"></string>
<string name="log_weight_dialog_description">Insert the weight:</string>
<string name="log_sleep_dialog_description">Set sleep duration:</string>
<string name="log_sleep_dialog_description_start">Start sleep cycle:</string>
<string name="measurement_unit_liquid_base_metric" translatable="false">ml</string>
<string name="measurement_unit_weight_base_metric" translatable="false">g</string>
@@ -141,6 +141,13 @@
<string name="measurement_unit_temperature_base_imperial" translatable="false">°F</string>
<string name="measurement_unit_temperature_base_metric" translatable="false">°C</string>
<string name="statistics_bottle_events">Bottle Events</string>
<string name="statistics_bottle_sum">Bottle Per Day</string>
<string name="statistics_medicine_events">Medicine Events</string>
<string name="statistics_sleep_sum">Sleep Per Day</string>
<string name="statistics_sleep_events">Sleep Events</string>
<string name="statistics_sleep_pattern">Sleep Pattern</string>
<string name="row_luna_event_description">Description</string>
<string name="row_luna_event_quantity">Qty</string>
<string name="row_luna_event_time">Time</string>
@@ -153,6 +160,11 @@
<string name="dialog_event_detail_notes">Notes</string>
<string name="dialog_event_detail_signature">by %s</string>
<string name="dialog_duration_button_asleep">asleep</string>
<string name="dialog_duration_button_minus_5">-5 min</string>
<string name="dialog_duration_button_now">now</string>
<string name="dialog_duration_button_plus_5">+5 min</string>
<string name="dialog_add_logbook_title">Add logbook</string>
<string name="dialog_add_logbook_logbookname">👶 Logbook name</string>
<string name="dialog_add_logbook_message">Write a name to identify this logbook. This name will appear on top of the screen and, if you use WebDAV, will be in the save file name as well.</string>