3 Commits

Author SHA1 Message Date
cbd18cd891 Add configurable buttons, separate settings screens and backup activity
- Add ButtonConfigActivity for customizing main screen buttons with
  drag-and-drop reordering and individual size options (S/M/L)
- Move storage settings to separate StorageSettingsActivity
- Move signature setting to storage settings (relevant for WebDAV sync)
- Move data backup to separate BackupActivity with export/import
- Make "more" overflow button configurable in size
- Simplify SettingsActivity to 3 navigation buttons
- Add logbook rename/delete functionality
- Improve S/M/L button contrast with visible borders
2026-01-11 21:31:49 +01:00
b6110c2cbb Add sleep tracking, statistics module and backup features
Features:
- Sleep tracking with timer and manual duration input
- Statistics module with 5 tabs (daily summary, feeding, diapers, sleep, growth)
- Export/Import backup functionality in settings
- Complete German, French and Italian translations
2026-01-08 09:33:36 +01:00
dccc89a8e2 Add CLAUDE.md and .claude/ to gitignore 2026-01-08 09:33:04 +01:00
11 changed files with 29 additions and 403 deletions

2
.gitignore vendored
View File

@@ -107,3 +107,5 @@ app/release/output-metadata.json
# Other # Other
app/src/main/java/it/danieleverducci/lunatracker/TemporaryHardcodedCredentials.kt app/src/main/java/it/danieleverducci/lunatracker/TemporaryHardcodedCredentials.kt
.kotlin/sessions/* .kotlin/sessions/*
CLAUDE.md
.claude/

View File

@@ -329,38 +329,18 @@ class MainActivity : AppCompatActivity() {
} }
fun startBreastfeedingTimer(eventType: String) { fun startBreastfeedingTimer(eventType: String) {
// Check if timer already running locally // Check if timer already running
if (bfTimerType != null) { if (bfTimerType != null) {
Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show()
return return
} }
if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.another_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Check logbook for existing ongoing breastfeeding event (from another device) // Save timer state
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) } bfTimerStartTime = System.currentTimeMillis()
if (existingOngoing != null) {
bfTimerStartTime = existingOngoing.time * 1000
bfTimerType = existingOngoing.type
saveBreastfeedingTimerState()
showBreastfeedingTimerDialog(existingOngoing.type)
return
}
// Create ongoing event and save to logbook (syncs via WebDAV)
val event = LunaEvent(eventType)
event.ongoing = true
event.signature = signature
bfTimerStartTime = event.time * 1000
bfTimerType = eventType bfTimerType = eventType
saveBreastfeedingTimerState() saveBreastfeedingTimerState()
logbook?.logs?.add(0, event) // Show timer dialog
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showBreastfeedingTimerDialog(eventType) showBreastfeedingTimerDialog(eventType)
} }
@@ -403,19 +383,13 @@ class MainActivity : AppCompatActivity() {
fun stopBreastfeedingTimer() { fun stopBreastfeedingTimer() {
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!) bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) } val durationMillis = System.currentTimeMillis() - bfTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
val eventType = bfTimerType val eventType = bfTimerType
clearBreastfeedingTimerState() clearBreastfeedingTimerState()
if (ongoingEvent != null) { if (eventType != null) {
ongoingEvent.finalizeOngoing(System.currentTimeMillis())
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
} else if (eventType != null) {
// Fallback: no ongoing event found (sync issue), create event the old way
val durationMillis = System.currentTimeMillis() - bfTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt())
logEvent(LunaEvent(eventType, durationMinutes)) logEvent(LunaEvent(eventType, durationMinutes))
} }
} }
@@ -423,23 +397,9 @@ class MainActivity : AppCompatActivity() {
fun cancelBreastfeedingTimer() { fun cancelBreastfeedingTimer() {
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!) bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
clearBreastfeedingTimerState() clearBreastfeedingTimerState()
// Remove ongoing event from logbook
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
if (ongoingEvent != null) {
logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint())
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
} }
fun askBreastfeedingDuration(eventType: String) { fun askBreastfeedingDuration(eventType: String) {
// Cancel any running timer to avoid orphaned ongoing events
if (bfTimerType != null) {
cancelBreastfeedingTimer()
}
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null) val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
d.setTitle(R.string.breastfeeding_duration_title) d.setTitle(R.string.breastfeeding_duration_title)
@@ -483,36 +443,17 @@ class MainActivity : AppCompatActivity() {
// Sleep timer methods // Sleep timer methods
fun startSleepTimer() { fun startSleepTimer() {
// Check if timer already running locally // Check if timer already running
if (sleepTimerStartTime > 0) { if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show()
return return
} }
if (bfTimerType != null) {
Toast.makeText(this, R.string.another_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Check logbook for existing ongoing sleep event (from another device) // Save timer state
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) } sleepTimerStartTime = System.currentTimeMillis()
if (existingOngoing != null) {
sleepTimerStartTime = existingOngoing.time * 1000
saveSleepTimerState()
showSleepTimerDialog()
return
}
// Create ongoing event and save to logbook (syncs via WebDAV)
val event = LunaEvent(LunaEvent.TYPE_SLEEP)
event.ongoing = true
event.signature = signature
sleepTimerStartTime = event.time * 1000
saveSleepTimerState() saveSleepTimerState()
logbook?.logs?.add(0, event) // Show timer dialog
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showSleepTimerDialog() showSleepTimerDialog()
} }
@@ -558,42 +499,20 @@ class MainActivity : AppCompatActivity() {
fun stopSleepTimer() { fun stopSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!) sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) } val durationMillis = System.currentTimeMillis() - sleepTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
clearSleepTimerState() clearSleepTimerState()
if (ongoingEvent != null) { logEvent(LunaEvent(LunaEvent.TYPE_SLEEP, durationMinutes))
ongoingEvent.finalizeOngoing(System.currentTimeMillis())
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
} else {
// Fallback: no ongoing event found (sync issue), create event the old way
val durationMillis = System.currentTimeMillis() - sleepTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt())
logEvent(LunaEvent(LunaEvent.TYPE_SLEEP, durationMinutes))
}
} }
fun cancelSleepTimer() { fun cancelSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!) sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
clearSleepTimerState() clearSleepTimerState()
// Remove ongoing event from logbook
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
if (ongoingEvent != null) {
logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint())
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
} }
fun askSleepDuration() { fun askSleepDuration() {
// Cancel any running timer to avoid orphaned ongoing events
if (sleepTimerStartTime > 0) {
cancelSleepTimer()
}
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null) val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
d.setTitle(R.string.sleep_duration_title) d.setTitle(R.string.sleep_duration_title)
@@ -910,27 +829,6 @@ class MainActivity : AppCompatActivity() {
} }
fun showEventDetailDialog(event: LunaEvent, items: ArrayList<LunaEvent>) { fun showEventDetailDialog(event: LunaEvent, items: ArrayList<LunaEvent>) {
// If event is ongoing, show the appropriate timer dialog instead
if (event.ongoing) {
if (event.type in LunaEvent.BREASTFEEDING_TYPES) {
if (bfTimerDialog?.isShowing == true) return
if (bfTimerType == null) {
bfTimerStartTime = event.time * 1000
bfTimerType = event.type
saveBreastfeedingTimerState()
}
showBreastfeedingTimerDialog(event.type)
} else if (event.type == LunaEvent.TYPE_SLEEP) {
if (sleepTimerDialog?.isShowing == true) return
if (sleepTimerStartTime == 0L) {
sleepTimerStartTime = event.time * 1000
saveSleepTimerState()
}
showSleepTimerDialog()
}
return
}
// Do not update list while the detail is shown, to avoid changing the object below while it is changed by the user // Do not update list while the detail is shown, to avoid changing the object below while it is changed by the user
pauseLogbookUpdate = true pauseLogbookUpdate = true
val d = AlertDialog.Builder(this) val d = AlertDialog.Builder(this)
@@ -959,8 +857,6 @@ class MainActivity : AppCompatActivity() {
TimePickerDialog(this, { _, hour, minute -> TimePickerDialog(this, { _, hour, minute ->
val pickedDateTime = Calendar.getInstance() val pickedDateTime = Calendar.getInstance()
pickedDateTime.set(year, month, day, hour, minute) pickedDateTime.set(year, month, day, hour, minute)
// Track old fingerprint so merge doesn't re-add the old version
logbook?.removedSinceLoad?.add(event.fingerprint())
// Save event and move it to the right position in the logbook // Save event and move it to the right position in the logbook
event.time = pickedDateTime.time.time / 1000 // Seconds since epoch event.time = pickedDateTime.time.time / 1000 // Seconds since epoch
dateTextView.text = String.format(getString(R.string.dialog_event_detail_datetime_icon), DateUtils.formatDateTime(event.time)) dateTextView.text = String.format(getString(R.string.dialog_event_detail_datetime_icon), DateUtils.formatDateTime(event.time))
@@ -994,20 +890,12 @@ class MainActivity : AppCompatActivity() {
) )
picker.minValue = 1 picker.minValue = 1
picker.maxValue = if (isSleep) 180 else 60 picker.maxValue = if (isSleep) 180 else 60
val oldQuantity = event.quantity
picker.value = if (event.quantity > 0) Math.min(event.quantity, picker.maxValue) else if (isSleep) 30 else 15 picker.value = if (event.quantity > 0) Math.min(event.quantity, picker.maxValue) else if (isSleep) 30 else 15
pickerDialog.setTitle(if (isSleep) R.string.sleep_duration_title else R.string.breastfeeding_duration_title) pickerDialog.setTitle(if (isSleep) R.string.sleep_duration_title else R.string.breastfeeding_duration_title)
pickerDialog.setView(pickerView) pickerDialog.setView(pickerView)
pickerDialog.setPositiveButton(android.R.string.ok) { _, _ -> pickerDialog.setPositiveButton(android.R.string.ok) { _, _ ->
val newQuantity = picker.value event.quantity = picker.value
if (newQuantity != oldQuantity) {
// Track old fingerprint so merge doesn't re-add the old version
logbook?.removedSinceLoad?.add(event.fingerprint())
// Adjust end time based on duration change (duration reduced = end time earlier)
event.time = event.time - (oldQuantity - newQuantity) * 60L
event.quantity = newQuantity
}
quantityTextView.text = NumericUtils(this@MainActivity).formatEventQuantity(event) quantityTextView.text = NumericUtils(this@MainActivity).formatEventQuantity(event)
recyclerView.adapter?.notifyDataSetChanged() recyclerView.adapter?.notifyDataSetChanged()
saveLogbook() saveLogbook()
@@ -1331,7 +1219,6 @@ class MainActivity : AppCompatActivity() {
findViewById<View>(R.id.no_connection_screen).visibility = View.GONE findViewById<View>(R.id.no_connection_screen).visibility = View.GONE
logbook = lb logbook = lb
showLogbook() showLogbook()
checkForOngoingEvents()
if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) { if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) {
for (e in logbook?.logs ?: listOf()) { for (e in logbook?.logs ?: listOf()) {
@@ -1390,71 +1277,6 @@ class MainActivity : AppCompatActivity() {
}) })
} }
fun checkForOngoingEvents() {
val logs = logbook?.logs ?: return
val now = System.currentTimeMillis() / 1000
val maxOngoingSeconds = 24 * 60 * 60 // 24 hours
// Check for stale ongoing events (>24h)
for (event in logs.toList()) {
if (event.ongoing && (now - event.time) > maxOngoingSeconds) {
showStaleTimerDialog(event)
return
}
}
// Breastfeeding timer sync
val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs)
if (bfTimerType == null) {
// Adopt ongoing timer from another device
if (ongoingBf != null) {
bfTimerStartTime = ongoingBf.time * 1000
bfTimerType = ongoingBf.type
saveBreastfeedingTimerState()
showBreastfeedingTimerDialog(ongoingBf.type)
}
} else if (ongoingBf == null) {
// Timer running locally but cancelled/stopped on other device
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
bfTimerDialog?.dismiss()
clearBreastfeedingTimerState()
}
// Sleep timer sync
val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP)
if (sleepTimerStartTime == 0L) {
// Adopt ongoing timer from another device
if (ongoingSleep != null) {
sleepTimerStartTime = ongoingSleep.time * 1000
saveSleepTimerState()
showSleepTimerDialog()
}
} else if (ongoingSleep == null) {
// Timer running locally but cancelled/stopped on other device
sleepTimerDialog?.dismiss()
clearSleepTimerState()
}
}
fun showStaleTimerDialog(event: LunaEvent) {
val d = AlertDialog.Builder(this)
d.setTitle(R.string.stale_timer_title)
d.setMessage(R.string.stale_timer_message)
d.setPositiveButton(R.string.stale_timer_finalize) { _, _ ->
event.finalizeOngoing(System.currentTimeMillis())
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
d.setNegativeButton(R.string.stale_timer_delete) { _, _ ->
logbook?.removedSinceLoad?.add(event.fingerprint())
logbook?.logs?.remove(event)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
d.create().show()
}
fun logEvent(event: LunaEvent) { fun logEvent(event: LunaEvent) {
savingEvent(true) savingEvent(true)
@@ -1478,7 +1300,6 @@ class MainActivity : AppCompatActivity() {
// Update data // Update data
setLoading(true) setLoading(true)
logbook?.removedSinceLoad?.add(event.fingerprint())
logbook?.logs?.remove(event) logbook?.logs?.remove(event)
recyclerView.adapter?.notifyDataSetChanged() recyclerView.adapter?.notifyDataSetChanged()
saveLogbook() saveLogbook()

View File

@@ -59,21 +59,6 @@ class LunaEventRecyclerAdapter: RecyclerView.Adapter<LunaEventRecyclerAdapter.Lu
LunaEvent.TYPE_CUSTOM -> item.notes LunaEvent.TYPE_CUSTOM -> item.notes
else -> item.getTypeDescription(context) else -> item.getTypeDescription(context)
} }
if (item.ongoing) {
// Show ongoing timer info
val elapsedSeconds = (System.currentTimeMillis() / 1000) - item.time
val minutes = elapsedSeconds / 60
val hours = minutes / 60
holder.time.text = if (hours > 0) {
String.format("%dh %02dm", hours, minutes % 60)
} else {
String.format("%d min", minutes)
}
holder.quantity.text = context.getString(R.string.event_ongoing)
holder.quantity.setTextColor(ContextCompat.getColor(context, R.color.accent))
return
}
holder.time.text = DateUtils.formatTimeAgo(context, item.time) holder.time.text = DateUtils.formatTimeAgo(context, item.time)
var quantityText = numericUtils.formatEventQuantity(item) var quantityText = numericUtils.formatEventQuantity(item)

View File

@@ -5,7 +5,6 @@ class Logbook(val name: String) {
const val MAX_SAFE_LOGBOOK_SIZE = 30000 const val MAX_SAFE_LOGBOOK_SIZE = 30000
} }
val logs = ArrayList<LunaEvent>() val logs = ArrayList<LunaEvent>()
val removedSinceLoad = mutableSetOf<String>()
fun isTooBig(): Boolean { fun isTooBig(): Boolean {
return logs.size > MAX_SAFE_LOGBOOK_SIZE return logs.size > MAX_SAFE_LOGBOOK_SIZE

View File

@@ -31,20 +31,6 @@ class LunaEvent: Comparable<LunaEvent> {
const val TYPE_PUKE = "PUKE" const val TYPE_PUKE = "PUKE"
const val TYPE_BATH = "BATH" const val TYPE_BATH = "BATH"
const val TYPE_SLEEP = "SLEEP" const val TYPE_SLEEP = "SLEEP"
val BREASTFEEDING_TYPES = listOf(
TYPE_BREASTFEEDING_LEFT_NIPPLE,
TYPE_BREASTFEEDING_BOTH_NIPPLE,
TYPE_BREASTFEEDING_RIGHT_NIPPLE
)
fun findOngoing(events: List<LunaEvent>, type: String): LunaEvent? {
return events.firstOrNull { it.ongoing && it.type == type }
}
fun findOngoingBreastfeeding(events: List<LunaEvent>): LunaEvent? {
return events.firstOrNull { it.ongoing && it.type in BREASTFEEDING_TYPES }
}
} }
private val jo: JSONObject private val jo: JSONObject
@@ -76,14 +62,6 @@ class LunaEvent: Comparable<LunaEvent> {
if (value.isNotEmpty()) if (value.isNotEmpty())
jo.put("signature", value) jo.put("signature", value)
} }
var ongoing: Boolean
get() = jo.optInt("ongoing", 0) == 1
set(value) {
if (value)
jo.put("ongoing", 1)
else
jo.remove("ongoing")
}
constructor(jo: JSONObject) { constructor(jo: JSONObject) {
this.jo = jo this.jo = jo
@@ -160,29 +138,6 @@ class LunaEvent: Comparable<LunaEvent> {
} }
} }
fun fingerprint(): String {
return "${time}_${type}_${quantity}_${notes}_${signature}_${ongoing}"
}
/**
* Returns the approximate start time for timer events.
* For ongoing events: time IS the start time.
* For finalized events: start = time - quantity * 60 (time=end, quantity=duration in min).
*/
fun approximateStartTime(): Long {
return if (ongoing) time else time - quantity * 60L
}
fun finalizeOngoing(endTimeMillis: Long) {
if (!ongoing) return
val startTimeSeconds = this.time
val endTimeSeconds = endTimeMillis / 1000
val durationMinutes = Math.max(1, ((endTimeSeconds - startTimeSeconds) / 60).toInt())
this.time = endTimeSeconds
this.quantity = durationMinutes
this.ongoing = false
}
fun toJson(): JSONObject { fun toJson(): JSONObject {
return jo return jo
} }

View File

@@ -133,125 +133,21 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
} }
private fun saveLogbook(context: Context, logbook: Logbook) { private fun saveLogbook(context: Context, logbook: Logbook) {
// Take a snapshot of local events (UI thread may modify logbook.logs concurrently) // Lock logbook on WebDAV to avoid concurrent changes
val localSnapshot = ArrayList(logbook.logs) //sardine.lock(getUrl())
val removedFingerprints = HashSet(logbook.removedSinceLoad) // Reload logbook from WebDAV
// Merge logbooks (based on time)
// Load remote logbook and merge before saving to avoid overwriting // Write logbook
// events added by other devices // Unlock logbook on WebDAV
val mergedEvents = ArrayList(localSnapshot) //sardine.unlock(getUrl())
try {
val remoteLogbook = loadLogbook(logbook.name)
val added = mergeRemoteEvents(mergedEvents, remoteLogbook, removedFingerprints)
if (added > 0) {
Log.d(TAG, "Merged $added remote events, saving ${mergedEvents.size} total")
}
// Merged events will appear in UI on next periodic load (max 60s)
} catch (e: Exception) {
// Remote not available (404, network error, etc.) - save local version as-is
Log.w(TAG, "Could not load remote logbook for merge: $e")
}
val ja = JSONArray() val ja = JSONArray()
for (l in mergedEvents) { for (l in logbook.logs) {
ja.put(l.toJson()) ja.put(l.toJson())
} }
sardine.put(getUrl(logbook.name), ja.toString().toByteArray()) sardine.put(getUrl(logbook.name), ja.toString().toByteArray())
} }
/**
* Merges remote events into the local event list.
* - Events already in local (by fingerprint) are skipped
* - Events in removedSinceLoad are skipped (deliberately deleted/cancelled locally)
* - Ongoing events that were finalized locally are skipped
* - Duplicate finalized timer events (same type + similar start time) are skipped
* @return number of events added
*/
private fun mergeRemoteEvents(
localEvents: ArrayList<LunaEvent>,
remote: Logbook,
removedFingerprints: Set<String>
): Int {
val localFingerprints = localEvents.map { it.fingerprint() }.toHashSet()
var added = 0
for (remoteEvent in remote.logs) {
val fingerprint = remoteEvent.fingerprint()
// Already exists locally with exact same state
if (localFingerprints.contains(fingerprint)) {
continue
}
// Was deliberately removed locally (cancel/delete)
if (removedFingerprints.contains(fingerprint)) {
continue
}
// Remote ongoing event was finalized locally
if (remoteEvent.ongoing && hasMatchingTimerEvent(remoteEvent, localEvents)) {
continue
}
// Remote finalized timer event that duplicates a local one
// (both devices stopped the same timer → slightly different end times)
if (!remoteEvent.ongoing && isDuplicateTimerEvent(remoteEvent, localEvents)) {
continue
}
localEvents.add(remoteEvent)
localFingerprints.add(fingerprint)
added++
}
if (added > 0) {
localEvents.sortWith(compareByDescending { it.time })
}
return added
}
/**
* Checks if there's a local timer event (ongoing or finalized) with
* approximately the same start time as the given event.
*/
private fun hasMatchingTimerEvent(event: LunaEvent, localEvents: List<LunaEvent>): Boolean {
val startTime = event.approximateStartTime()
for (local in localEvents) {
if (local.type != event.type) continue
if (Math.abs(startTime - local.approximateStartTime()) < 120) {
return true
}
}
return false
}
/**
* Checks if a finalized timer event is a duplicate of an existing local event
* (same timer stopped on two devices → slightly different end times).
* Only applies to timer-type events (sleep, breastfeeding) with duration > 0.
*/
private fun isDuplicateTimerEvent(event: LunaEvent, localEvents: List<LunaEvent>): Boolean {
if (event.quantity <= 0) return false
val timerTypes = setOf(
LunaEvent.TYPE_SLEEP,
LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE,
LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE,
LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE
)
if (event.type !in timerTypes) return false
val startTime = event.approximateStartTime()
for (local in localEvents) {
if (local.ongoing) continue
if (local.type != event.type) continue
if (local.quantity <= 0) continue
if (Math.abs(startTime - local.approximateStartTime()) < 120) {
return true
}
}
return false
}
/** /**
* Connect to server and check if a logbook already exists. * Connect to server and check if a logbook already exists.
* If it does not exist, try to upload the local one. * If it does not exist, try to upload the local one.

View File

@@ -79,14 +79,14 @@ class StatisticsCalculator(private val events: List<LunaEvent>) {
} }
private fun getEventsInRange(startUnix: Long, endUnix: Long): List<LunaEvent> { private fun getEventsInRange(startUnix: Long, endUnix: Long): List<LunaEvent> {
return events.filter { it.time >= startUnix && it.time < endUnix && !it.ongoing } return events.filter { it.time >= startUnix && it.time < endUnix }
} }
private fun getEventsForDays(days: Int): List<LunaEvent> { private fun getEventsForDays(days: Int): List<LunaEvent> {
val now = System.currentTimeMillis() / 1000 val now = System.currentTimeMillis() / 1000
val startOfToday = getStartOfDay(now) val startOfToday = getStartOfDay(now)
val startTime = startOfToday - (days - 1) * 24 * 60 * 60 val startTime = startOfToday - (days - 1) * 24 * 60 * 60
return events.filter { it.time >= startTime && !it.ongoing } return events.filter { it.time >= startTime }
} }
/** /**

View File

@@ -104,7 +104,6 @@
<string name="breastfeeding_timer_stop">Stopp</string> <string name="breastfeeding_timer_stop">Stopp</string>
<string name="breastfeeding_timer_hint">Tippe Stopp wenn fertig</string> <string name="breastfeeding_timer_hint">Tippe Stopp wenn fertig</string>
<string name="breastfeeding_timer_already_running">Es läuft bereits eine Stillsitzung</string> <string name="breastfeeding_timer_already_running">Es läuft bereits eine Stillsitzung</string>
<string name="another_timer_already_running">Es läuft bereits ein anderer Timer</string>
<string name="breastfeeding_duration_title">Stilldauer</string> <string name="breastfeeding_duration_title">Stilldauer</string>
<string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string> <string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string>
@@ -237,13 +236,6 @@
<string name="import_success">%d Ereignisse importiert</string> <string name="import_success">%d Ereignisse importiert</string>
<string name="import_error">Import fehlgeschlagen</string> <string name="import_error">Import fehlgeschlagen</string>
<!-- Laufende Events (geräteübergreifende Timer-Synchronisierung) -->
<string name="event_ongoing">läuft…</string>
<string name="stale_timer_title">Abgelaufener Timer</string>
<string name="stale_timer_message">Ein Timer läuft seit über 24 Stunden. Wurde er vergessen?</string>
<string name="stale_timer_finalize">Mit aktueller Dauer speichern</string>
<string name="stale_timer_delete">Löschen</string>
<!-- Logbook-Verwaltung --> <!-- Logbook-Verwaltung -->
<string name="edit_logbook_title">Logbook bearbeiten</string> <string name="edit_logbook_title">Logbook bearbeiten</string>
<string name="edit_logbook_name_hint">Logbook-Name</string> <string name="edit_logbook_name_hint">Logbook-Name</string>

View File

@@ -104,7 +104,6 @@
<string name="breastfeeding_timer_stop">Arrêter</string> <string name="breastfeeding_timer_stop">Arrêter</string>
<string name="breastfeeding_timer_hint">Appuyez sur Arrêter quand terminé</string> <string name="breastfeeding_timer_hint">Appuyez sur Arrêter quand terminé</string>
<string name="breastfeeding_timer_already_running">Une session d\'allaitement est déjà en cours</string> <string name="breastfeeding_timer_already_running">Une session d\'allaitement est déjà en cours</string>
<string name="another_timer_already_running">Un autre minuteur est déjà en cours</string>
<string name="breastfeeding_duration_title">Durée d\'allaitement</string> <string name="breastfeeding_duration_title">Durée d\'allaitement</string>
<string name="breastfeeding_duration_description">Entrez la durée en minutes</string> <string name="breastfeeding_duration_description">Entrez la durée en minutes</string>
@@ -205,13 +204,6 @@
<string name="import_success">%d événements importés</string> <string name="import_success">%d événements importés</string>
<string name="import_error">Échec de l\'import</string> <string name="import_error">Échec de l\'import</string>
<!-- Événements en cours (synchronisation minuterie inter-appareils) -->
<string name="event_ongoing">en cours…</string>
<string name="stale_timer_title">Minuterie expirée</string>
<string name="stale_timer_message">Une minuterie est active depuis plus de 24 heures. A-t-elle été oubliée ?</string>
<string name="stale_timer_finalize">Enregistrer avec la durée actuelle</string>
<string name="stale_timer_delete">Supprimer</string>
<!-- Gestion du journal --> <!-- Gestion du journal -->
<string name="edit_logbook_title">Modifier le journal</string> <string name="edit_logbook_title">Modifier le journal</string>
<string name="edit_logbook_name_hint">Nom du journal</string> <string name="edit_logbook_name_hint">Nom du journal</string>

View File

@@ -104,7 +104,6 @@
<string name="breastfeeding_timer_stop">Stop</string> <string name="breastfeeding_timer_stop">Stop</string>
<string name="breastfeeding_timer_hint">Premi Stop quando hai finito</string> <string name="breastfeeding_timer_hint">Premi Stop quando hai finito</string>
<string name="breastfeeding_timer_already_running">Una sessione di allattamento è già in corso</string> <string name="breastfeeding_timer_already_running">Una sessione di allattamento è già in corso</string>
<string name="another_timer_already_running">Un altro timer è già in corso</string>
<string name="breastfeeding_duration_title">Durata allattamento</string> <string name="breastfeeding_duration_title">Durata allattamento</string>
<string name="breastfeeding_duration_description">Inserisci la durata in minuti</string> <string name="breastfeeding_duration_description">Inserisci la durata in minuti</string>
@@ -205,13 +204,6 @@
<string name="import_success">%d eventi importati</string> <string name="import_success">%d eventi importati</string>
<string name="import_error">Importazione fallita</string> <string name="import_error">Importazione fallita</string>
<!-- Eventi in corso (sincronizzazione timer tra dispositivi) -->
<string name="event_ongoing">in corso…</string>
<string name="stale_timer_title">Timer scaduto</string>
<string name="stale_timer_message">Un timer è attivo da oltre 24 ore. È stato dimenticato?</string>
<string name="stale_timer_finalize">Salva con la durata attuale</string>
<string name="stale_timer_delete">Elimina</string>
<!-- Gestione diario --> <!-- Gestione diario -->
<string name="edit_logbook_title">Modifica diario</string> <string name="edit_logbook_title">Modifica diario</string>
<string name="edit_logbook_name_hint">Nome del diario</string> <string name="edit_logbook_name_hint">Nome del diario</string>

View File

@@ -162,7 +162,6 @@
<string name="breastfeeding_timer_stop">Stop</string> <string name="breastfeeding_timer_stop">Stop</string>
<string name="breastfeeding_timer_hint">Tap Stop when finished</string> <string name="breastfeeding_timer_hint">Tap Stop when finished</string>
<string name="breastfeeding_timer_already_running">A breastfeeding session is already in progress</string> <string name="breastfeeding_timer_already_running">A breastfeeding session is already in progress</string>
<string name="another_timer_already_running">Another timer is already running</string>
<string name="breastfeeding_duration_title">Breastfeeding duration</string> <string name="breastfeeding_duration_title">Breastfeeding duration</string>
<string name="breastfeeding_duration_description">Enter the duration in minutes</string> <string name="breastfeeding_duration_description">Enter the duration in minutes</string>
<string name="measurement_unit_time_minutes" translatable="false">min</string> <string name="measurement_unit_time_minutes" translatable="false">min</string>
@@ -263,13 +262,6 @@
<string name="import_success">Imported %d events</string> <string name="import_success">Imported %d events</string>
<string name="import_error">Import failed</string> <string name="import_error">Import failed</string>
<!-- Ongoing events (cross-device timer sync) -->
<string name="event_ongoing">running…</string>
<string name="stale_timer_title">Expired timer</string>
<string name="stale_timer_message">A timer has been running for more than 24 hours. Was it forgotten?</string>
<string name="stale_timer_finalize">Save with current duration</string>
<string name="stale_timer_delete">Delete</string>
<!-- Logbook management --> <!-- Logbook management -->
<string name="edit_logbook_title">Edit Logbook</string> <string name="edit_logbook_title">Edit Logbook</string>
<string name="edit_logbook_name_hint">Logbook name</string> <string name="edit_logbook_name_hint">Logbook name</string>