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>
This commit is contained in:
2026-02-13 17:36:13 +01:00
parent 0776e4d6c2
commit 81473f8f9f
8 changed files with 227 additions and 18 deletions

View File

@@ -329,18 +329,34 @@ class MainActivity : AppCompatActivity() {
}
fun startBreastfeedingTimer(eventType: String) {
// Check if timer already running
// Check if timer already running locally
if (bfTimerType != null) {
Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Save timer state
bfTimerStartTime = System.currentTimeMillis()
// Check logbook for existing ongoing breastfeeding event (from another device)
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
saveBreastfeedingTimerState()
// Show timer dialog
logbook?.logs?.add(0, event)
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showBreastfeedingTimerDialog(eventType)
}
@@ -383,13 +399,19 @@ class MainActivity : AppCompatActivity() {
fun stopBreastfeedingTimer() {
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
val durationMillis = System.currentTimeMillis() - bfTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
val eventType = bfTimerType
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))
}
}
@@ -397,6 +419,14 @@ class MainActivity : AppCompatActivity() {
fun cancelBreastfeedingTimer() {
bfTimerHandler?.removeCallbacks(bfTimerRunnable!!)
clearBreastfeedingTimerState()
// Remove ongoing event from logbook
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
if (ongoingEvent != null) {
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
}
fun askBreastfeedingDuration(eventType: String) {
@@ -443,17 +473,32 @@ class MainActivity : AppCompatActivity() {
// Sleep timer methods
fun startSleepTimer() {
// Check if timer already running
// Check if timer already running locally
if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Save timer state
sleepTimerStartTime = System.currentTimeMillis()
// Check logbook for existing ongoing sleep event (from another device)
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()
// Show timer dialog
logbook?.logs?.add(0, event)
recyclerView.adapter?.notifyItemInserted(0)
saveLogbook()
showSleepTimerDialog()
}
@@ -499,17 +544,33 @@ class MainActivity : AppCompatActivity() {
fun stopSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
val durationMillis = System.currentTimeMillis() - sleepTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
clearSleepTimerState()
logEvent(LunaEvent(LunaEvent.TYPE_SLEEP, durationMinutes))
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))
}
}
fun cancelSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
clearSleepTimerState()
// Remove ongoing event from logbook
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
if (ongoingEvent != null) {
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
}
fun askSleepDuration() {
@@ -829,6 +890,25 @@ class MainActivity : AppCompatActivity() {
}
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 (bfTimerType == null) {
bfTimerStartTime = event.time * 1000
bfTimerType = event.type
saveBreastfeedingTimerState()
}
showBreastfeedingTimerDialog(event.type)
} else if (event.type == LunaEvent.TYPE_SLEEP) {
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
pauseLogbookUpdate = true
val d = AlertDialog.Builder(this)
@@ -1225,6 +1305,7 @@ class MainActivity : AppCompatActivity() {
findViewById<View>(R.id.no_connection_screen).visibility = View.GONE
logbook = lb
showLogbook()
checkForOngoingEvents()
if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) {
for (e in logbook?.logs ?: listOf()) {
@@ -1283,6 +1364,59 @@ 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
}
}
// Check for ongoing breastfeeding from another device
if (bfTimerType == null) {
val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs)
if (ongoingBf != null) {
bfTimerStartTime = ongoingBf.time * 1000
bfTimerType = ongoingBf.type
saveBreastfeedingTimerState()
showBreastfeedingTimerDialog(ongoingBf.type)
}
}
// Check for ongoing sleep from another device
if (sleepTimerStartTime == 0L) {
val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP)
if (ongoingSleep != null) {
sleepTimerStartTime = ongoingSleep.time * 1000
saveSleepTimerState()
showSleepTimerDialog()
}
}
}
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?.logs?.remove(event)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
d.create().show()
}
fun logEvent(event: LunaEvent) {
savingEvent(true)

View File

@@ -59,6 +59,21 @@ class LunaEventRecyclerAdapter: RecyclerView.Adapter<LunaEventRecyclerAdapter.Lu
LunaEvent.TYPE_CUSTOM -> item.notes
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)
var quantityText = numericUtils.formatEventQuantity(item)

View File

@@ -31,6 +31,20 @@ class LunaEvent: Comparable<LunaEvent> {
const val TYPE_PUKE = "PUKE"
const val TYPE_BATH = "BATH"
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
@@ -62,6 +76,14 @@ class LunaEvent: Comparable<LunaEvent> {
if (value.isNotEmpty())
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) {
this.jo = jo
@@ -138,6 +160,16 @@ class LunaEvent: Comparable<LunaEvent> {
}
}
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 {
return jo
}

View File

@@ -79,14 +79,14 @@ class StatisticsCalculator(private val events: 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> {
val now = System.currentTimeMillis() / 1000
val startOfToday = getStartOfDay(now)
val startTime = startOfToday - (days - 1) * 24 * 60 * 60
return events.filter { it.time >= startTime }
return events.filter { it.time >= startTime && !it.ongoing }
}
/**

View File

@@ -236,6 +236,13 @@
<string name="import_success">%d Ereignisse importiert</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 -->
<string name="edit_logbook_title">Logbook bearbeiten</string>
<string name="edit_logbook_name_hint">Logbook-Name</string>

View File

@@ -204,6 +204,13 @@
<string name="import_success">%d événements importés</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 -->
<string name="edit_logbook_title">Modifier le journal</string>
<string name="edit_logbook_name_hint">Nom du journal</string>

View File

@@ -204,6 +204,13 @@
<string name="import_success">%d eventi importati</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 -->
<string name="edit_logbook_title">Modifica diario</string>
<string name="edit_logbook_name_hint">Nome del diario</string>

View File

@@ -262,6 +262,13 @@
<string name="import_success">Imported %d events</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 -->
<string name="edit_logbook_title">Edit Logbook</string>
<string name="edit_logbook_name_hint">Logbook name</string>