7 Commits

Author SHA1 Message Date
96422da7eb Fix timer edge cases: thread safety, duplicate dialogs, concurrent timers
- Remove background thread mutation of logbook.logs during merge to
  prevent ConcurrentModificationException crashes
- Cancel ongoing timer when user enters manual duration to prevent
  orphaned ongoing events
- Prevent duplicate timer dialogs when tapping ongoing event in list
- Block starting a second timer while another is already running
- Track old fingerprint on event edit to prevent merge re-adding
  the old version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:43:44 +01:00
c0e0ec8f51 Fix merge re-adding deleted/cancelled events and timer sync
- Track removed events via removedSinceLoad set in Logbook to prevent
  merge from re-adding deliberately deleted or cancelled events
- Deduplicate finalized timer events (same type + similar start time)
  to prevent duplicates when both devices stop the same timer
- Detect timer cancellation from other device: dismiss local timer
  dialog when ongoing event disappears from logbook after sync
- Fix thread safety: take snapshot of events before background merge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:30:50 +01:00
235a355a05 Add merge-on-save to WebDAV sync to prevent event loss
Before saving to WebDAV, the remote logbook is loaded and merged
with the local version. Events from other devices that don't exist
locally are added before uploading. This prevents one device from
overwriting events added by another device between sync intervals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:11:38 +01:00
81473f8f9f Add cross-device timer sync via WebDAV
When a sleep or breastfeeding timer is started, an "ongoing" event is
immediately saved to the logbook and synced via WebDAV. Other devices
detect this event on sync and can display/stop the timer. This allows
partners to stop timers started on another device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:36:13 +01:00
0776e4d6c2 Fix time display after manual duration correction
When a timer (sleep/breastfeeding) was forgotten and the duration is
manually corrected, the "X minutes ago" display now reflects the adjusted
end time instead of the original stop time.
2026-01-17 22:08:19 +01:00
3e8af97757 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-17 21:37:11 +01:00
6a995d6561 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-17 21:37:05 +01:00
11 changed files with 403 additions and 29 deletions

2
.gitignore vendored
View File

@@ -107,5 +107,3 @@ 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,18 +329,38 @@ class MainActivity : AppCompatActivity() {
} }
fun startBreastfeedingTimer(eventType: String) { fun startBreastfeedingTimer(eventType: String) {
// Check if timer already running // Check if timer already running locally
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
}
// Save timer state // Check logbook for existing ongoing breastfeeding event (from another device)
bfTimerStartTime = System.currentTimeMillis() val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
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()
// Show timer dialog logbook?.logs?.add(0, event)
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showBreastfeedingTimerDialog(eventType) showBreastfeedingTimerDialog(eventType)
} }
@@ -383,13 +403,19 @@ class MainActivity : AppCompatActivity() {
fun stopBreastfeedingTimer() { fun stopBreastfeedingTimer() {
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!) bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
val durationMillis = System.currentTimeMillis() - bfTimerStartTime val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
val eventType = bfTimerType val eventType = bfTimerType
clearBreastfeedingTimerState() clearBreastfeedingTimerState()
if (eventType != null) { if (ongoingEvent != 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))
} }
} }
@@ -397,9 +423,23 @@ 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)
@@ -443,17 +483,36 @@ class MainActivity : AppCompatActivity() {
// Sleep timer methods // Sleep timer methods
fun startSleepTimer() { fun startSleepTimer() {
// Check if timer already running // Check if timer already running locally
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
}
// Save timer state // Check logbook for existing ongoing sleep event (from another device)
sleepTimerStartTime = System.currentTimeMillis() val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
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()
// Show timer dialog logbook?.logs?.add(0, event)
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showSleepTimerDialog() showSleepTimerDialog()
} }
@@ -499,20 +558,42 @@ class MainActivity : AppCompatActivity() {
fun stopSleepTimer() { fun stopSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!) sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
val durationMillis = System.currentTimeMillis() - sleepTimerStartTime val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
clearSleepTimerState() clearSleepTimerState()
if (ongoingEvent != null) {
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)) 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)
@@ -829,6 +910,27 @@ 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)
@@ -857,6 +959,8 @@ 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))
@@ -890,12 +994,20 @@ 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) { _, _ ->
event.quantity = picker.value val newQuantity = 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()
@@ -1219,6 +1331,7 @@ 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()) {
@@ -1277,6 +1390,71 @@ 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)
@@ -1300,6 +1478,7 @@ 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,6 +59,21 @@ 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,6 +5,7 @@ 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,6 +31,20 @@ 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
@@ -62,6 +76,14 @@ 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
@@ -138,6 +160,29 @@ 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,21 +133,125 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
} }
private fun saveLogbook(context: Context, logbook: Logbook) { private fun saveLogbook(context: Context, logbook: Logbook) {
// Lock logbook on WebDAV to avoid concurrent changes // Take a snapshot of local events (UI thread may modify logbook.logs concurrently)
//sardine.lock(getUrl()) val localSnapshot = ArrayList(logbook.logs)
// Reload logbook from WebDAV val removedFingerprints = HashSet(logbook.removedSinceLoad)
// Merge logbooks (based on time)
// Write logbook // Load remote logbook and merge before saving to avoid overwriting
// Unlock logbook on WebDAV // events added by other devices
//sardine.unlock(getUrl()) val mergedEvents = ArrayList(localSnapshot)
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 logbook.logs) { for (l in mergedEvents) {
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 } return events.filter { it.time >= startUnix && it.time < endUnix && !it.ongoing }
} }
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 } return events.filter { it.time >= startTime && !it.ongoing }
} }
/** /**

View File

@@ -104,6 +104,7 @@
<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>
@@ -236,6 +237,13 @@
<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,6 +104,7 @@
<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>
@@ -204,6 +205,13 @@
<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,6 +104,7 @@
<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>
@@ -204,6 +205,13 @@
<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,6 +162,7 @@
<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>
@@ -262,6 +263,13 @@
<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>