forked from penguin86/luna-tracker
Compare commits
7 Commits
cbd18cd891
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
| 96422da7eb | |||
| c0e0ec8f51 | |||
| 235a355a05 | |||
| 81473f8f9f | |||
| 0776e4d6c2 | |||
| 3e8af97757 | |||
| 6a995d6561 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user