forked from penguin86/luna-tracker
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user