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
33 changed files with 2382 additions and 737 deletions

2
.gitignore vendored
View File

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

View File

@@ -19,6 +19,10 @@ android {
}
buildTypes {
debug {
applicationIdSuffix = ".theo"
resValue("string", "app_name", "Theotracker")
}
release {
isMinifyEnabled = false
proguardFiles(
@@ -52,6 +56,7 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.recyclerview)
implementation("com.github.thegrizzlylabs:sardine-android:v0.9")
implementation("com.google.android.flexbox:flexbox:3.0.0")
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -34,6 +34,18 @@
android:name=".StatisticsActivity"
android:label="@string/statistics_title"
android:theme="@style/Theme.LunaTracker"/>
<activity
android:name=".ButtonConfigActivity"
android:label="@string/button_config_title"
android:theme="@style/Theme.LunaTracker"/>
<activity
android:name=".StorageSettingsActivity"
android:label="@string/storage_settings_title"
android:theme="@style/Theme.LunaTracker"/>
<activity
android:name=".BackupActivity"
android:label="@string/backup_title"
android:theme="@style/Theme.LunaTracker"/>
</application>
</manifest>

View File

@@ -0,0 +1,177 @@
package it.danieleverducci.lunatracker
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.progressindicator.LinearProgressIndicator
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import org.json.JSONArray
import org.json.JSONObject
/**
* Activity for backup functionality (export/import logbooks).
*/
class BackupActivity : AppCompatActivity() {
private lateinit var progressIndicator: LinearProgressIndicator
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri -> uri?.let { exportLogbookToUri(it) } }
private val importLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { importLogbookFromUri(it) } }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_backup)
progressIndicator = findViewById(R.id.progress_indicator)
findViewById<View>(R.id.backup_export).setOnClickListener {
startExport()
}
findViewById<View>(R.id.backup_import).setOnClickListener {
startImport()
}
findViewById<View>(R.id.backup_close).setOnClickListener {
finish()
}
}
private fun startExport() {
val timestamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US)
.format(java.util.Date())
exportLauncher.launch("lunatracker_backup_$timestamp.json")
}
private fun startImport() {
importLauncher.launch(arrayOf("application/json"))
}
private fun exportLogbookToUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
val json = JSONObject().apply {
put("version", 1)
put("app", "LunaTracker")
put("exported_at", System.currentTimeMillis())
put("logbooks", JSONArray().apply {
logbooks.forEach { logbook ->
put(JSONObject().apply {
put("name", logbook.name)
put("events", JSONArray().apply {
logbook.logs.forEach { event ->
put(event.toJson())
}
})
})
}
})
}
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(json.toString(2).toByteArray(Charsets.UTF_8))
}
val eventCount = logbooks.sumOf { it.logs.size }
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_success, eventCount),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_error) + e.message,
Toast.LENGTH_SHORT
).show()
}
}
}.start()
}
private fun importLogbookFromUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val jsonString = contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: throw Exception("Could not read file")
val json = JSONObject(jsonString)
val fileLogbookRepo = FileLogbookRepository()
var totalEvents = 0
if (json.has("logbooks")) {
// New format with multiple logbooks
val logbooksArray = json.getJSONArray("logbooks")
for (i in 0 until logbooksArray.length()) {
val logbookJson = logbooksArray.getJSONObject(i)
val name = logbookJson.optString("name", "")
val eventsArray = logbookJson.getJSONArray("events")
val logbook = Logbook(name)
for (j in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(j)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
} else if (json.has("events")) {
// Old format with single logbook
val name = json.optString("logbook_name", "")
val eventsArray = json.getJSONArray("events")
val logbook = Logbook(name)
for (i in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(i)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_success, totalEvents),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_error) + ": " + e.message,
Toast.LENGTH_LONG
).show()
}
}
}.start()
}
}

View File

@@ -0,0 +1,71 @@
package it.danieleverducci.lunatracker
import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import it.danieleverducci.lunatracker.adapters.ButtonConfigAdapter
import it.danieleverducci.lunatracker.adapters.ButtonConfigItemTouchHelperCallback
import it.danieleverducci.lunatracker.repository.ButtonConfigRepository
/**
* Activity for configuring which buttons appear on the main screen
* and in what order.
*/
class ButtonConfigActivity : AppCompatActivity() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: ButtonConfigAdapter
private lateinit var itemTouchHelper: ItemTouchHelper
private lateinit var repository: ButtonConfigRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_button_config)
repository = ButtonConfigRepository(this)
setupRecyclerView()
setupButtons()
}
private fun setupRecyclerView() {
recyclerView = findViewById(R.id.button_list)
recyclerView.layoutManager = LinearLayoutManager(this)
// Load current configuration
val configs = repository.loadConfigs().toMutableList()
adapter = ButtonConfigAdapter(configs) { viewHolder ->
itemTouchHelper.startDrag(viewHolder)
}
recyclerView.adapter = adapter
// Setup drag-and-drop
val callback = ButtonConfigItemTouchHelperCallback(adapter)
itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper.attachToRecyclerView(recyclerView)
}
private fun setupButtons() {
findViewById<Button>(R.id.btn_save).setOnClickListener {
saveConfiguration()
}
findViewById<Button>(R.id.btn_cancel).setOnClickListener {
finish()
}
}
private fun saveConfiguration() {
val configs = adapter.getConfigs()
repository.saveConfigs(configs)
Toast.makeText(this, R.string.button_config_saved, Toast.LENGTH_SHORT).show()
finish()
}
}

View File

@@ -11,6 +11,7 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
@@ -21,6 +22,7 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.flexbox.FlexboxLayout
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -29,14 +31,18 @@ import com.google.android.material.slider.Slider
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.adapters.DaySeparatorDecoration
import it.danieleverducci.lunatracker.adapters.LunaEventRecyclerAdapter
import it.danieleverducci.lunatracker.entities.ButtonConfig
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.repository.ButtonConfigRepository
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener
import it.danieleverducci.lunatracker.repository.LogbookLoadedListener
import it.danieleverducci.lunatracker.repository.LogbookRepository
import it.danieleverducci.lunatracker.repository.LogbookSavedListener
import it.danieleverducci.lunatracker.repository.LogbookRenamedListener
import it.danieleverducci.lunatracker.repository.LogbookDeletedListener
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import kotlinx.coroutines.Runnable
import okio.IOException
@@ -56,7 +62,7 @@ class MainActivity : AppCompatActivity() {
var logbook: Logbook? = null
var pauseLogbookUpdate = false
lateinit var progressIndicator: LinearProgressIndicator
lateinit var buttonsContainer: ViewGroup
lateinit var buttonsContainer: FlexboxLayout
lateinit var recyclerView: RecyclerView
lateinit var handler: Handler
var signature = ""
@@ -97,43 +103,7 @@ class MainActivity : AppCompatActivity() {
// Set listeners
findViewById<View>(R.id.logbooks_add_button).setOnClickListener { showAddLogbookDialog(true) }
findViewById<View>(R.id.button_bottle).setOnClickListener { askBabyBottleContent() }
findViewById<View>(R.id.button_food).setOnClickListener { askNotes(LunaEvent(LunaEvent.TYPE_FOOD)) }
findViewById<View>(R.id.button_nipple_left).setOnClickListener {
startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE)
}
findViewById<View>(R.id.button_nipple_left).setOnLongClickListener {
askBreastfeedingDuration(LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE)
true
}
findViewById<View>(R.id.button_nipple_both).setOnClickListener {
startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE)
}
findViewById<View>(R.id.button_nipple_both).setOnLongClickListener {
askBreastfeedingDuration(LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE)
true
}
findViewById<View>(R.id.button_nipple_right).setOnClickListener {
startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE)
}
findViewById<View>(R.id.button_nipple_right).setOnLongClickListener {
askBreastfeedingDuration(LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE)
true
}
findViewById<View>(R.id.button_change_poo).setOnClickListener { logEvent(
LunaEvent(
LunaEvent.TYPE_DIAPERCHANGE_POO
)
) }
findViewById<View>(R.id.button_change_pee).setOnClickListener { logEvent(
LunaEvent(
LunaEvent.TYPE_DIAPERCHANGE_PEE
)
) }
val moreButton = findViewById<View>(R.id.button_more)
moreButton.setOnClickListener {
showOverflowPopupWindow(moreButton)
}
findViewById<View>(R.id.logbooks_edit_button).setOnClickListener { showEditLogbookDialog() }
findViewById<View>(R.id.button_no_connection_settings).setOnClickListener {
showSettings()
}
@@ -207,11 +177,8 @@ class MainActivity : AppCompatActivity() {
signature = settingsRepository.loadSignature()
val noBreastfeeding = settingsRepository.loadNoBreastfeeding()
findViewById<View>(R.id.layout_nipples).visibility = when (noBreastfeeding) {
true -> View.GONE
false -> View.VISIBLE
}
// Render buttons based on configuration
renderButtons()
// Update list dates
recyclerView.adapter?.notifyDataSetChanged()
@@ -362,18 +329,38 @@ 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
}
if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.another_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)
}
@@ -416,13 +403,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))
}
}
@@ -430,9 +423,23 @@ 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?.removedSinceLoad?.add(ongoingEvent.fingerprint())
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
}
fun askBreastfeedingDuration(eventType: String) {
// Cancel any running timer to avoid orphaned ongoing events
if (bfTimerType != null) {
cancelBreastfeedingTimer()
}
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
d.setTitle(R.string.breastfeeding_duration_title)
@@ -476,17 +483,36 @@ 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
}
if (bfTimerType != null) {
Toast.makeText(this, R.string.another_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()
}
@@ -532,20 +558,42 @@ 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?.removedSinceLoad?.add(ongoingEvent.fingerprint())
logbook?.logs?.remove(ongoingEvent)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
}
}
fun askSleepDuration() {
// Cancel any running timer to avoid orphaned ongoing events
if (sleepTimerStartTime > 0) {
cancelSleepTimer()
}
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
d.setTitle(R.string.sleep_duration_title)
@@ -585,6 +633,234 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Renders buttons dynamically based on the saved configuration.
*/
fun renderButtons() {
buttonsContainer.removeAllViews()
val buttonConfigRepo = ButtonConfigRepository(this)
val allConfigs = buttonConfigRepo.loadConfigs()
val visibleConfigs = allConfigs.filter { it.visible }.sortedBy { it.order }
val hiddenConfigs = allConfigs.filter { !it.visible }
val marginPx = resources.getDimensionPixelSize(R.dimen.button_margin)
for (config in visibleConfigs) {
// Get size-specific dimensions for this button
val (textSizeRes, paddingRes, minWidthRes) = when (config.size) {
ButtonConfigRepository.SIZE_SMALL -> Triple(
R.dimen.button_text_size_small,
R.dimen.button_padding_small,
R.dimen.button_min_width_small
)
ButtonConfigRepository.SIZE_LARGE -> Triple(
R.dimen.button_text_size_large,
R.dimen.button_padding_large,
R.dimen.button_min_width_large
)
else -> Triple(
R.dimen.button_text_size_medium,
R.dimen.button_padding_medium,
R.dimen.button_min_width_medium
)
}
val textSizePx = resources.getDimension(textSizeRes)
val paddingPx = resources.getDimensionPixelSize(paddingRes)
val minWidthPx = resources.getDimensionPixelSize(minWidthRes)
val button = TextView(this).apply {
text = getString(config.iconResId)
setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSizePx)
gravity = android.view.Gravity.CENTER
background = ContextCompat.getDrawable(this@MainActivity, R.drawable.button_background)
val lp = FlexboxLayout.LayoutParams(
FlexboxLayout.LayoutParams.WRAP_CONTENT,
FlexboxLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(marginPx, marginPx, marginPx, marginPx)
flexGrow = 1f
minWidth = minWidthPx
}
layoutParams = lp
setPadding(paddingPx, paddingPx, paddingPx, paddingPx)
// Set click listener based on button type
setOnClickListener { handleButtonClick(config.id) }
// Set long click listener for breastfeeding and sleep buttons
if (config.id in listOf(
ButtonConfig.ID_BREASTFEEDING_LEFT,
ButtonConfig.ID_BREASTFEEDING_BOTH,
ButtonConfig.ID_BREASTFEEDING_RIGHT
)) {
setOnLongClickListener {
askBreastfeedingDuration(ButtonConfig.getEventType(config.id))
true
}
} else if (config.id == ButtonConfig.ID_SLEEP) {
setOnLongClickListener {
askSleepDuration()
true
}
}
}
buttonsContainer.addView(button)
}
// Add "More" button if there are hidden buttons (excluding MORE itself)
val hiddenConfigsWithoutMore = hiddenConfigs.filter { it.id != ButtonConfig.ID_MORE }
if (hiddenConfigsWithoutMore.isNotEmpty()) {
// Get size configuration for the More button
val moreConfig = allConfigs.find { it.id == ButtonConfig.ID_MORE }
val moreSize = moreConfig?.size ?: ButtonConfigRepository.SIZE_MEDIUM
val (moreTextSizeRes, morePaddingRes, moreMinWidthRes) = when (moreSize) {
ButtonConfigRepository.SIZE_SMALL -> Triple(
R.dimen.button_text_size_small,
R.dimen.button_padding_small,
R.dimen.button_min_width_small
)
ButtonConfigRepository.SIZE_LARGE -> Triple(
R.dimen.button_text_size_large,
R.dimen.button_padding_large,
R.dimen.button_min_width_large
)
else -> Triple(
R.dimen.button_text_size_medium,
R.dimen.button_padding_medium,
R.dimen.button_min_width_medium
)
}
val textSizePx = resources.getDimension(moreTextSizeRes)
val paddingPx = resources.getDimensionPixelSize(morePaddingRes)
val minWidthPx = resources.getDimensionPixelSize(moreMinWidthRes)
val moreButton = TextView(this).apply {
text = getString(R.string.event_more_type)
setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSizePx)
gravity = android.view.Gravity.CENTER
background = ContextCompat.getDrawable(this@MainActivity, R.drawable.button_background)
val lp = FlexboxLayout.LayoutParams(
FlexboxLayout.LayoutParams.WRAP_CONTENT,
FlexboxLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(marginPx, marginPx, marginPx, marginPx)
flexGrow = 1f
minWidth = minWidthPx
}
layoutParams = lp
setPadding(paddingPx, paddingPx, paddingPx, paddingPx)
setOnClickListener { showMoreButtonsPopup(it, hiddenConfigsWithoutMore) }
}
buttonsContainer.addView(moreButton)
}
}
/**
* Shows a popup with all hidden buttons.
*/
private fun showMoreButtonsPopup(anchor: View, hiddenConfigs: List<ButtonConfig>) {
if (showingOverflowPopupWindow)
return
val popupView = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16, 16, 16, 16)
setBackgroundColor(ContextCompat.getColor(this@MainActivity, R.color.transparent))
}
hiddenConfigs.forEach { config ->
val item = TextView(this).apply {
text = "${getString(config.iconResId)} ${getString(config.labelResId)}"
textSize = 18f
setPadding(20, 20, 20, 20)
background = ContextCompat.getDrawable(this@MainActivity, R.drawable.dropdown_list_item_background)
val itemLp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
itemLp.topMargin = 8
layoutParams = itemLp
}
popupView.addView(item)
}
val popupWindow = PopupWindow(
popupView,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
true
).apply {
isOutsideTouchable = true
setOnDismissListener {
Handler(mainLooper).postDelayed({
showingOverflowPopupWindow = false
}, 500)
}
}
// Set click listeners after popup is created
for (i in 0 until popupView.childCount) {
val item = popupView.getChildAt(i) as TextView
val config = hiddenConfigs[i]
item.setOnClickListener {
handleButtonClick(config.id)
popupWindow.dismiss()
}
// Long click for breastfeeding and sleep buttons
if (config.id in listOf(
ButtonConfig.ID_BREASTFEEDING_LEFT,
ButtonConfig.ID_BREASTFEEDING_BOTH,
ButtonConfig.ID_BREASTFEEDING_RIGHT
)) {
item.setOnLongClickListener {
askBreastfeedingDuration(ButtonConfig.getEventType(config.id))
popupWindow.dismiss()
true
}
} else if (config.id == ButtonConfig.ID_SLEEP) {
item.setOnLongClickListener {
askSleepDuration()
popupWindow.dismiss()
true
}
}
}
popupWindow.showAsDropDown(anchor)
showingOverflowPopupWindow = true
}
/**
* Handles a button click based on the button ID.
*/
private fun handleButtonClick(buttonId: String) {
when (buttonId) {
ButtonConfig.ID_BOTTLE -> askBabyBottleContent()
ButtonConfig.ID_BREASTFEEDING_LEFT -> startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE)
ButtonConfig.ID_BREASTFEEDING_BOTH -> startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE)
ButtonConfig.ID_BREASTFEEDING_RIGHT -> startBreastfeedingTimer(LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE)
ButtonConfig.ID_FOOD -> askNotes(LunaEvent(LunaEvent.TYPE_FOOD))
ButtonConfig.ID_DIAPER_POO -> logEvent(LunaEvent(LunaEvent.TYPE_DIAPERCHANGE_POO))
ButtonConfig.ID_DIAPER_PEE -> logEvent(LunaEvent(LunaEvent.TYPE_DIAPERCHANGE_PEE))
ButtonConfig.ID_SLEEP -> startSleepTimer()
ButtonConfig.ID_MEDICINE -> askNotes(LunaEvent(LunaEvent.TYPE_MEDICINE))
ButtonConfig.ID_TEMPERATURE -> askTemperatureValue()
ButtonConfig.ID_NOTE -> askNotes(LunaEvent(LunaEvent.TYPE_NOTE))
ButtonConfig.ID_PUKE -> askPukeValue()
ButtonConfig.ID_COLIC -> logEvent(LunaEvent(LunaEvent.TYPE_COLIC))
ButtonConfig.ID_WEIGHT -> askWeightValue()
ButtonConfig.ID_BATH -> logEvent(LunaEvent(LunaEvent.TYPE_BATH))
ButtonConfig.ID_ENEMA -> logEvent(LunaEvent(LunaEvent.TYPE_ENEMA))
}
}
fun askToTrimLogbook() {
val d = AlertDialog.Builder(this)
d.setTitle(R.string.trim_logbook_dialog_title)
@@ -634,6 +910,27 @@ 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 (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
pauseLogbookUpdate = true
val d = AlertDialog.Builder(this)
@@ -662,6 +959,8 @@ class MainActivity : AppCompatActivity() {
TimePickerDialog(this, { _, hour, minute ->
val pickedDateTime = Calendar.getInstance()
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
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))
@@ -695,12 +994,20 @@ class MainActivity : AppCompatActivity() {
)
picker.minValue = 1
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
pickerDialog.setTitle(if (isSleep) R.string.sleep_duration_title else R.string.breastfeeding_duration_title)
pickerDialog.setView(pickerView)
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)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()
@@ -780,6 +1087,114 @@ class MainActivity : AppCompatActivity() {
alertDialog.show()
}
fun showEditLogbookDialog() {
if (logbook == null) return
val d = AlertDialog.Builder(this)
d.setTitle(R.string.edit_logbook_title)
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_logbook, null)
val logbookNameEditText = dialogView.findViewById<EditText>(R.id.dialog_edit_logbook_name)
logbookNameEditText.setText(logbook?.name ?: "")
val deleteButton = dialogView.findViewById<View>(R.id.dialog_edit_logbook_delete)
deleteButton.setOnClickListener {
showDeleteLogbookConfirmDialog()
}
d.setView(dialogView)
d.setPositiveButton(R.string.rename_logbook) { dialogInterface, i ->
val newName = logbookNameEditText.text.toString().trim()
if (newName.isNotEmpty() && newName != logbook?.name) {
renameLogbook(logbook?.name ?: "", newName)
}
}
d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() }
d.create().show()
}
fun showDeleteLogbookConfirmDialog() {
val currentName = logbook?.name ?: return
val displayName = currentName.ifEmpty { getString(R.string.default_logbook_name) }
AlertDialog.Builder(this)
.setTitle(R.string.delete_logbook)
.setMessage(getString(R.string.delete_logbook_confirm, displayName))
.setPositiveButton(R.string.delete_logbook) { _, _ ->
deleteLogbook(currentName)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
fun renameLogbook(oldName: String, newName: String) {
setLoading(true)
logbookRepo?.renameLogbook(this, oldName, newName, object : LogbookRenamedListener {
override fun onLogbookRenamed() {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_renamed, Toast.LENGTH_SHORT).show()
loadLogbookList()
}
}
override fun onIOError(error: IOException) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_rename_error, Toast.LENGTH_SHORT).show()
}
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_rename_error, Toast.LENGTH_SHORT).show()
}
}
override fun onError(error: Exception) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_rename_error, Toast.LENGTH_SHORT).show()
}
}
})
}
fun deleteLogbook(name: String) {
setLoading(true)
logbookRepo?.deleteLogbook(this, name, object : LogbookDeletedListener {
override fun onLogbookDeleted() {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_deleted, Toast.LENGTH_SHORT).show()
logbook = null
loadLogbookList()
}
}
override fun onIOError(error: IOException) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_delete_error, Toast.LENGTH_SHORT).show()
}
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_delete_error, Toast.LENGTH_SHORT).show()
}
}
override fun onError(error: Exception) {
runOnUiThread {
setLoading(false)
Toast.makeText(this@MainActivity, R.string.logbook_delete_error, Toast.LENGTH_SHORT).show()
}
}
})
}
fun loadLogbookList() {
setLoading(true)
logbookRepo?.listLogbooks(this, object: LogbookListObtainedListener {
@@ -916,6 +1331,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()) {
@@ -974,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) {
savingEvent(true)
@@ -997,6 +1478,7 @@ class MainActivity : AppCompatActivity() {
// Update data
setLoading(true)
logbook?.removedSinceLoad?.add(event.fingerprint())
logbook?.logs?.remove(event)
recyclerView.adapter?.notifyDataSetChanged()
saveLogbook()

View File

@@ -1,354 +1,61 @@
package it.danieleverducci.lunatracker
import android.net.Uri
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.switchmaterial.SwitchMaterial
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import okio.IOException
import org.json.JSONArray
import org.json.JSONObject
open class SettingsActivity : AppCompatActivity() {
protected lateinit var settingsRepository: LocalSettingsRepository
protected lateinit var radioDataLocal: RadioButton
protected lateinit var radioDataWebDAV: RadioButton
protected lateinit var textViewWebDAVUrl: TextView
protected lateinit var textViewWebDAVUser: TextView
protected lateinit var textViewWebDAVPass: TextView
protected lateinit var progressIndicator: LinearProgressIndicator
protected lateinit var switchNoBreastfeeding: SwitchMaterial
protected lateinit var textViewSignature: EditText
class SettingsActivity : AppCompatActivity() {
// Activity Result Launchers for Export/Import
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri -> uri?.let { exportLogbookToUri(it) } }
private lateinit var settingsRepository: LocalSettingsRepository
private lateinit var textViewStorageStatus: TextView
private val importLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { importLogbookFromUri(it) } }
private val storageSettingsLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
updateStorageStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
radioDataLocal = findViewById(R.id.settings_data_local)
radioDataWebDAV = findViewById(R.id.settings_data_webdav)
textViewWebDAVUrl = findViewById(R.id.settings_data_webdav_url)
textViewWebDAVUser = findViewById(R.id.settings_data_webdav_user)
textViewWebDAVPass = findViewById(R.id.settings_data_webdav_pass)
progressIndicator = findViewById(R.id.progress_indicator)
switchNoBreastfeeding = findViewById(R.id.switch_no_breastfeeding)
textViewSignature = findViewById(R.id.settings_signature)
textViewStorageStatus = findViewById(R.id.settings_storage_status)
findViewById<View>(R.id.settings_save).setOnClickListener({
validateAndSave()
})
findViewById<View>(R.id.settings_cancel).setOnClickListener({
findViewById<View>(R.id.settings_storage).setOnClickListener {
storageSettingsLauncher.launch(Intent(this, StorageSettingsActivity::class.java))
}
findViewById<View>(R.id.settings_button_config).setOnClickListener {
startActivity(Intent(this, ButtonConfigActivity::class.java))
}
findViewById<View>(R.id.settings_backup).setOnClickListener {
startActivity(Intent(this, BackupActivity::class.java))
}
findViewById<View>(R.id.settings_close).setOnClickListener {
finish()
})
findViewById<View>(R.id.settings_export).setOnClickListener({
startExport()
})
findViewById<View>(R.id.settings_import).setOnClickListener({
startImport()
})
}
settingsRepository = LocalSettingsRepository(this)
loadSettings()
}
fun loadSettings() {
override fun onResume() {
super.onResume()
updateStorageStatus()
}
private fun loadSettings() {
updateStorageStatus()
}
private fun updateStorageStatus() {
val dataRepo = settingsRepository.loadDataRepository()
val webDavCredentials = settingsRepository.loadWebdavCredentials()
val noBreastfeeding = settingsRepository.loadNoBreastfeeding()
val signature = settingsRepository.loadSignature()
when (dataRepo) {
LocalSettingsRepository.DATA_REPO.LOCAL_FILE -> radioDataLocal.isChecked = true
LocalSettingsRepository.DATA_REPO.WEBDAV -> radioDataWebDAV.isChecked = true
}
textViewSignature.setText(signature)
switchNoBreastfeeding.isChecked = noBreastfeeding
if (webDavCredentials != null) {
textViewWebDAVUrl.text = webDavCredentials[0]
textViewWebDAVUser.text = webDavCredentials[1]
textViewWebDAVPass.text = webDavCredentials[2]
textViewStorageStatus.text = when (dataRepo) {
LocalSettingsRepository.DATA_REPO.WEBDAV -> getString(R.string.settings_storage_status_webdav)
else -> getString(R.string.settings_storage_status_local)
}
}
fun validateAndSave() {
if (radioDataLocal.isChecked) {
// No validation required, just save
saveSettings()
return
}
// Try to connect to WebDAV and check if the save file already exists
val webDAVLogbookRepo = WebDAVLogbookRepository(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
progressIndicator.visibility = View.VISIBLE
webDAVLogbookRepo.listLogbooks(this, object: LogbookListObtainedListener{
override fun onLogbookListObtained(logbooksNames: ArrayList<String>) {
if (logbooksNames.isEmpty()) {
// TODO: Ask the user if he wants to upload the local ones or to create a new one
copyLocalLogbooksToWebdav(webDAVLogbookRepo, object: OnCopyLocalLogbooksToWebdavFinishedListener {
override fun onCopyLocalLogbooksToWebdavFinished(errors: String?) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if (errors == null) {
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, errors, Toast.LENGTH_SHORT).show()
}
})
}
})
} else {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
})
}
}
override fun onIOError(error: IOException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_network_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if(error.toString().contains("401")) {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_denied), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_generic) + error.toString(), Toast.LENGTH_SHORT).show()
}
})
}
override fun onError(error: Exception) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_generic_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
})
/*webDAVLogbookRepo.createLogbook(this, LogbookRepository.DEFAULT_LOGBOOK_NAME, object: WebDAVLogbookRepository.LogbookCreatedListener{
override fun onJSONError(error: JSONException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_json_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
})*/
}
fun saveSettings() {
settingsRepository.saveDataRepository(
if (radioDataWebDAV.isChecked) LocalSettingsRepository.DATA_REPO.WEBDAV
else LocalSettingsRepository.DATA_REPO.LOCAL_FILE
)
settingsRepository.saveNoBreastfeeding(switchNoBreastfeeding.isChecked)
settingsRepository.saveSignature(textViewSignature.text.toString())
settingsRepository.saveWebdavCredentials(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
finish()
}
/**
* Copies the local logbooks to webdav.
* @return success
*/
private fun copyLocalLogbooksToWebdav(webDAVLogbookRepository: WebDAVLogbookRepository, listener: OnCopyLocalLogbooksToWebdavFinishedListener) {
Thread(Runnable {
val errors = StringBuilder()
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
for (logbook in logbooks) {
// Copy only if does not already exist
val error = webDAVLogbookRepository.uploadLogbookIfNotExists(this, logbook.name)
if (error != null) {
if (errors.isNotEmpty())
errors.append("\n")
errors.append(String.format(getString(R.string.settings_webdav_upload_error), logbook.name, error))
}
}
listener.onCopyLocalLogbooksToWebdavFinished(
if (errors.isEmpty()) null else errors.toString()
)
}).start()
}
private interface OnCopyLocalLogbooksToWebdavFinishedListener {
fun onCopyLocalLogbooksToWebdavFinished(errors: String?)
}
// Export/Import functionality
private fun startExport() {
val timestamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US)
.format(java.util.Date())
exportLauncher.launch("lunatracker_backup_$timestamp.json")
}
private fun startImport() {
importLauncher.launch(arrayOf("application/json"))
}
private fun exportLogbookToUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
val json = JSONObject().apply {
put("version", 1)
put("app", "LunaTracker")
put("exported_at", System.currentTimeMillis())
put("logbooks", JSONArray().apply {
logbooks.forEach { logbook ->
put(JSONObject().apply {
put("name", logbook.name)
put("events", JSONArray().apply {
logbook.logs.forEach { event ->
put(event.toJson())
}
})
})
}
})
}
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(json.toString(2).toByteArray(Charsets.UTF_8))
}
val eventCount = logbooks.sumOf { it.logs.size }
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_success, eventCount),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_error) + e.message,
Toast.LENGTH_SHORT
).show()
}
}
}.start()
}
private fun importLogbookFromUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val jsonString = contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: throw Exception("Could not read file")
val json = JSONObject(jsonString)
val version = json.optInt("version", 1)
val fileLogbookRepo = FileLogbookRepository()
var totalEvents = 0
if (json.has("logbooks")) {
// New format with multiple logbooks
val logbooksArray = json.getJSONArray("logbooks")
for (i in 0 until logbooksArray.length()) {
val logbookJson = logbooksArray.getJSONObject(i)
val name = logbookJson.optString("name", "")
val eventsArray = logbookJson.getJSONArray("events")
val logbook = Logbook(name)
for (j in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(j)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
} else if (json.has("events")) {
// Old format with single logbook
val name = json.optString("logbook_name", "")
val eventsArray = json.getJSONArray("events")
val logbook = Logbook(name)
for (i in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(i)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_success, totalEvents),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_error) + ": " + e.message,
Toast.LENGTH_LONG
).show()
}
}
}.start()
}
}

View File

@@ -0,0 +1,178 @@
package it.danieleverducci.lunatracker
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.RadioButton
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import okio.IOException
/**
* Activity for configuring storage settings (Local/WebDAV).
*/
class StorageSettingsActivity : AppCompatActivity() {
private lateinit var settingsRepository: LocalSettingsRepository
private lateinit var radioDataLocal: RadioButton
private lateinit var radioDataWebDAV: RadioButton
private lateinit var textViewWebDAVUrl: EditText
private lateinit var textViewWebDAVUser: EditText
private lateinit var textViewWebDAVPass: EditText
private lateinit var textViewSignature: EditText
private lateinit var progressIndicator: LinearProgressIndicator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_storage_settings)
radioDataLocal = findViewById(R.id.settings_data_local)
radioDataWebDAV = findViewById(R.id.settings_data_webdav)
textViewWebDAVUrl = findViewById(R.id.settings_data_webdav_url)
textViewWebDAVUser = findViewById(R.id.settings_data_webdav_user)
textViewWebDAVPass = findViewById(R.id.settings_data_webdav_pass)
textViewSignature = findViewById(R.id.settings_signature)
progressIndicator = findViewById(R.id.progress_indicator)
findViewById<View>(R.id.settings_save).setOnClickListener {
validateAndSave()
}
findViewById<View>(R.id.settings_cancel).setOnClickListener {
finish()
}
settingsRepository = LocalSettingsRepository(this)
loadSettings()
}
private fun loadSettings() {
val dataRepo = settingsRepository.loadDataRepository()
val webDavCredentials = settingsRepository.loadWebdavCredentials()
when (dataRepo) {
LocalSettingsRepository.DATA_REPO.LOCAL_FILE -> radioDataLocal.isChecked = true
LocalSettingsRepository.DATA_REPO.WEBDAV -> radioDataWebDAV.isChecked = true
}
if (webDavCredentials != null) {
textViewWebDAVUrl.setText(webDavCredentials[0])
textViewWebDAVUser.setText(webDavCredentials[1])
textViewWebDAVPass.setText(webDavCredentials[2])
}
textViewSignature.setText(settingsRepository.loadSignature())
}
private fun validateAndSave() {
if (radioDataLocal.isChecked) {
saveSettings()
return
}
// Try to connect to WebDAV and check if the save file already exists
val webDAVLogbookRepo = WebDAVLogbookRepository(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
progressIndicator.visibility = View.VISIBLE
webDAVLogbookRepo.listLogbooks(this, object : LogbookListObtainedListener {
override fun onLogbookListObtained(logbooksNames: ArrayList<String>) {
if (logbooksNames.isEmpty()) {
copyLocalLogbooksToWebdav(webDAVLogbookRepo, object : OnCopyLocalLogbooksToWebdavFinishedListener {
override fun onCopyLocalLogbooksToWebdavFinished(errors: String?) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
if (errors == null) {
saveSettings()
Toast.makeText(this@StorageSettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@StorageSettingsActivity, errors, Toast.LENGTH_SHORT).show()
}
}
}
})
} else {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
saveSettings()
Toast.makeText(this@StorageSettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
}
}
}
override fun onIOError(error: IOException) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@StorageSettingsActivity, getString(R.string.settings_network_error) + error.toString(), Toast.LENGTH_SHORT).show()
}
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
if (error.toString().contains("401")) {
Toast.makeText(this@StorageSettingsActivity, getString(R.string.settings_webdav_error_denied), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@StorageSettingsActivity, getString(R.string.settings_webdav_error_generic) + error.toString(), Toast.LENGTH_SHORT).show()
}
}
}
override fun onError(error: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@StorageSettingsActivity, getString(R.string.settings_generic_error) + error.toString(), Toast.LENGTH_SHORT).show()
}
}
})
}
private fun saveSettings() {
settingsRepository.saveDataRepository(
if (radioDataWebDAV.isChecked) LocalSettingsRepository.DATA_REPO.WEBDAV
else LocalSettingsRepository.DATA_REPO.LOCAL_FILE
)
settingsRepository.saveWebdavCredentials(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
settingsRepository.saveSignature(textViewSignature.text.toString())
setResult(RESULT_OK)
finish()
}
private fun copyLocalLogbooksToWebdav(webDAVLogbookRepository: WebDAVLogbookRepository, listener: OnCopyLocalLogbooksToWebdavFinishedListener) {
Thread {
val errors = StringBuilder()
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
for (logbook in logbooks) {
val error = webDAVLogbookRepository.uploadLogbookIfNotExists(this, logbook.name)
if (error != null) {
if (errors.isNotEmpty())
errors.append("\n")
errors.append(String.format(getString(R.string.settings_webdav_upload_error), logbook.name, error))
}
}
listener.onCopyLocalLogbooksToWebdavFinished(
if (errors.isEmpty()) null else errors.toString()
)
}.start()
}
private interface OnCopyLocalLogbooksToWebdavFinishedListener {
fun onCopyLocalLogbooksToWebdavFinished(errors: String?)
}
}

View File

@@ -0,0 +1,170 @@
package it.danieleverducci.lunatracker.adapters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.entities.ButtonConfig
import it.danieleverducci.lunatracker.repository.ButtonConfigRepository
import java.util.Collections
/**
* Adapter for the button configuration list with drag-and-drop reordering.
*/
class ButtonConfigAdapter(
private val configs: MutableList<ButtonConfig>,
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
) : RecyclerView.Adapter<ButtonConfigAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_button_config, parent, false)
return ViewHolder(view)
}
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val config = configs[position]
val context = holder.itemView.context
holder.icon.text = context.getString(config.iconResId)
holder.label.text = context.getString(config.labelResId)
// Hide checkbox for MORE button (it's shown dynamically when there are hidden buttons)
if (config.id == ButtonConfig.ID_MORE) {
holder.checkbox.visibility = View.INVISIBLE
} else {
holder.checkbox.visibility = View.VISIBLE
holder.checkbox.isChecked = config.visible
}
holder.checkbox.setOnCheckedChangeListener { _, isChecked ->
config.visible = isChecked
}
// Start drag when touching the drag handle
holder.dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onStartDrag(holder)
}
false
}
// Size buttons
updateSizeButtons(holder, config.size)
holder.sizeSmall.setOnClickListener {
config.size = ButtonConfigRepository.SIZE_SMALL
updateSizeButtons(holder, config.size)
}
holder.sizeMedium.setOnClickListener {
config.size = ButtonConfigRepository.SIZE_MEDIUM
updateSizeButtons(holder, config.size)
}
holder.sizeLarge.setOnClickListener {
config.size = ButtonConfigRepository.SIZE_LARGE
updateSizeButtons(holder, config.size)
}
}
private fun updateSizeButtons(holder: ViewHolder, size: Int) {
holder.sizeSmall.isSelected = (size == ButtonConfigRepository.SIZE_SMALL)
holder.sizeMedium.isSelected = (size == ButtonConfigRepository.SIZE_MEDIUM)
holder.sizeLarge.isSelected = (size == ButtonConfigRepository.SIZE_LARGE)
}
override fun getItemCount(): Int = configs.size
/**
* Moves an item from one position to another.
* Called by ItemTouchHelper during drag.
*/
fun moveItem(fromPosition: Int, toPosition: Int) {
if (fromPosition < toPosition) {
for (i in fromPosition until toPosition) {
Collections.swap(configs, i, i + 1)
}
} else {
for (i in fromPosition downTo toPosition + 1) {
Collections.swap(configs, i, i - 1)
}
}
notifyItemMoved(fromPosition, toPosition)
updateOrders()
}
/**
* Updates the order field of all configs based on their current positions.
*/
private fun updateOrders() {
configs.forEachIndexed { index, config ->
config.order = index
}
}
/**
* Returns the current configuration list.
*/
fun getConfigs(): List<ButtonConfig> = configs.toList()
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val dragHandle: ImageView = itemView.findViewById(R.id.drag_handle)
val checkbox: CheckBox = itemView.findViewById(R.id.checkbox_visible)
val icon: TextView = itemView.findViewById(R.id.icon)
val label: TextView = itemView.findViewById(R.id.label)
val sizeSmall: TextView = itemView.findViewById(R.id.size_small)
val sizeMedium: TextView = itemView.findViewById(R.id.size_medium)
val sizeLarge: TextView = itemView.findViewById(R.id.size_large)
}
}
/**
* ItemTouchHelper.Callback for drag-and-drop reordering.
*/
class ButtonConfigItemTouchHelperCallback(
private val adapter: ButtonConfigAdapter
) : ItemTouchHelper.Callback() {
override fun isLongPressDragEnabled(): Boolean = false // We use drag handle instead
override fun isItemViewSwipeEnabled(): Boolean = false // No swipe to delete
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
return makeMovementFlags(dragFlags, 0)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
adapter.moveItem(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Not used
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.alpha = 0.7f
}
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = 1.0f
}
}

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

@@ -0,0 +1,90 @@
package it.danieleverducci.lunatracker.entities
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.repository.ButtonConfigRepository
/**
* Represents a button configuration for the main screen.
* Users can configure which buttons are visible, in what order, and at what size.
*/
data class ButtonConfig(
val id: String, // Button identifier (matches LunaEvent.TYPE_*)
val iconResId: Int, // R.string.event_*_type (emoji)
val labelResId: Int, // R.string.event_*_desc (description)
var visible: Boolean, // Show on main screen?
var order: Int, // Display order (0 = first)
var size: Int = ButtonConfigRepository.SIZE_MEDIUM // Button size (S/M/L)
) {
companion object {
// Button IDs matching LunaEvent types
const val ID_BOTTLE = "bottle"
const val ID_BREASTFEEDING_LEFT = "breastfeeding_left"
const val ID_BREASTFEEDING_BOTH = "breastfeeding_both"
const val ID_BREASTFEEDING_RIGHT = "breastfeeding_right"
const val ID_FOOD = "food"
const val ID_DIAPER_POO = "diaper_poo"
const val ID_DIAPER_PEE = "diaper_pee"
const val ID_SLEEP = "sleep"
const val ID_MEDICINE = "medicine"
const val ID_TEMPERATURE = "temperature"
const val ID_NOTE = "note"
const val ID_PUKE = "puke"
const val ID_COLIC = "colic"
const val ID_WEIGHT = "weight"
const val ID_BATH = "bath"
const val ID_ENEMA = "enema"
const val ID_MORE = "more"
/**
* Returns the default button configuration.
* First 7 buttons are visible by default, rest are hidden.
*/
fun getDefaultConfigs(): List<ButtonConfig> {
return listOf(
ButtonConfig(ID_BOTTLE, R.string.event_bottle_type, R.string.event_bottle_desc, true, 0),
ButtonConfig(ID_BREASTFEEDING_LEFT, R.string.event_breastfeeding_left_type, R.string.event_breastfeeding_left_desc, true, 1),
ButtonConfig(ID_BREASTFEEDING_BOTH, R.string.event_breastfeeding_both_type, R.string.event_breastfeeding_both_desc, true, 2),
ButtonConfig(ID_BREASTFEEDING_RIGHT, R.string.event_breastfeeding_right_type, R.string.event_breastfeeding_right_desc, true, 3),
ButtonConfig(ID_FOOD, R.string.event_food_type, R.string.event_food_desc, true, 4),
ButtonConfig(ID_DIAPER_POO, R.string.event_diaperchange_poo_type, R.string.event_diaperchange_poo_desc, true, 5),
ButtonConfig(ID_DIAPER_PEE, R.string.event_diaperchange_pee_type, R.string.event_diaperchange_pee_desc, true, 6),
ButtonConfig(ID_SLEEP, R.string.event_sleep_type, R.string.event_sleep_desc, false, 7),
ButtonConfig(ID_MEDICINE, R.string.event_medicine_type, R.string.event_medicine_desc, false, 8),
ButtonConfig(ID_TEMPERATURE, R.string.event_temperature_type, R.string.event_temperature_desc, false, 9),
ButtonConfig(ID_NOTE, R.string.event_note_type, R.string.event_note_desc, false, 10),
ButtonConfig(ID_PUKE, R.string.event_puke_type, R.string.event_puke_desc, false, 11),
ButtonConfig(ID_COLIC, R.string.event_colic_type, R.string.event_colic_desc, false, 12),
ButtonConfig(ID_WEIGHT, R.string.event_scale_type, R.string.event_scale_desc, false, 13),
ButtonConfig(ID_BATH, R.string.event_bath_type, R.string.event_bath_desc, false, 14),
ButtonConfig(ID_ENEMA, R.string.event_enema_type, R.string.event_enema_desc, false, 15),
// More button - always last, visibility controlled dynamically
ButtonConfig(ID_MORE, R.string.event_more_type, R.string.event_more_desc, false, 16)
)
}
/**
* Maps button ID to LunaEvent type constant.
*/
fun getEventType(buttonId: String): String {
return when (buttonId) {
ID_BOTTLE -> LunaEvent.TYPE_BABY_BOTTLE
ID_BREASTFEEDING_LEFT -> LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE
ID_BREASTFEEDING_BOTH -> LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE
ID_BREASTFEEDING_RIGHT -> LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE
ID_FOOD -> LunaEvent.TYPE_FOOD
ID_DIAPER_POO -> LunaEvent.TYPE_DIAPERCHANGE_POO
ID_DIAPER_PEE -> LunaEvent.TYPE_DIAPERCHANGE_PEE
ID_SLEEP -> LunaEvent.TYPE_SLEEP
ID_MEDICINE -> LunaEvent.TYPE_MEDICINE
ID_TEMPERATURE -> LunaEvent.TYPE_TEMPERATURE
ID_NOTE -> LunaEvent.TYPE_NOTE
ID_PUKE -> LunaEvent.TYPE_PUKE
ID_COLIC -> LunaEvent.TYPE_COLIC
ID_WEIGHT -> LunaEvent.TYPE_WEIGHT
ID_BATH -> LunaEvent.TYPE_BATH
ID_ENEMA -> LunaEvent.TYPE_ENEMA
else -> throw IllegalArgumentException("Unknown button ID: $buttonId")
}
}
}
}

View File

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

View File

@@ -0,0 +1,111 @@
package it.danieleverducci.lunatracker.repository
import android.content.Context
import android.content.SharedPreferences
import it.danieleverducci.lunatracker.entities.ButtonConfig
import org.json.JSONArray
import org.json.JSONObject
/**
* Repository for saving and loading button configurations.
* Stores the configuration as a JSON array in SharedPreferences.
*/
class ButtonConfigRepository(context: Context) {
companion object {
private const val PREFS_NAME = "button_config"
private const val KEY_CONFIGS = "configs"
// Button size constants
const val SIZE_SMALL = 0
const val SIZE_MEDIUM = 1
const val SIZE_LARGE = 2
}
private val sharedPreferences: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* Saves the button configurations to SharedPreferences.
*/
fun saveConfigs(configs: List<ButtonConfig>) {
val jsonArray = JSONArray()
configs.forEach { config ->
val jsonObject = JSONObject().apply {
put("id", config.id)
put("visible", config.visible)
put("order", config.order)
put("size", config.size)
}
jsonArray.put(jsonObject)
}
sharedPreferences.edit()
.putString(KEY_CONFIGS, jsonArray.toString())
.apply()
}
/**
* Loads the button configurations from SharedPreferences.
* Returns default configuration if no saved config exists.
*/
fun loadConfigs(): List<ButtonConfig> {
val jsonString = sharedPreferences.getString(KEY_CONFIGS, null)
?: return ButtonConfig.getDefaultConfigs()
return try {
val jsonArray = JSONArray(jsonString)
// Triple: visible, order, size
val savedConfigs = mutableMapOf<String, Triple<Boolean, Int, Int>>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val id = jsonObject.getString("id")
val visible = jsonObject.getBoolean("visible")
val order = jsonObject.getInt("order")
val size = jsonObject.optInt("size", SIZE_MEDIUM)
savedConfigs[id] = Triple(visible, order, size)
}
// Merge saved configs with defaults (in case new buttons were added)
val defaultConfigs = ButtonConfig.getDefaultConfigs()
val mergedConfigs = defaultConfigs.map { defaultConfig ->
val saved = savedConfigs[defaultConfig.id]
if (saved != null) {
defaultConfig.copy(visible = saved.first, order = saved.second, size = saved.third)
} else {
defaultConfig
}
}
// Sort by order
mergedConfigs.sortedBy { it.order }
} catch (e: Exception) {
ButtonConfig.getDefaultConfigs()
}
}
/**
* Returns only the visible buttons, sorted by order.
*/
fun loadVisibleConfigs(): List<ButtonConfig> {
return loadConfigs()
.filter { it.visible }
.sortedBy { it.order }
}
/**
* Resets the configuration to defaults.
*/
fun resetToDefaults() {
sharedPreferences.edit()
.remove(KEY_CONFIGS)
.apply()
}
/**
* Checks if a custom configuration has been saved.
*/
fun hasCustomConfig(): Boolean {
return sharedPreferences.contains(KEY_CONFIGS)
}
}

View File

@@ -105,6 +105,38 @@ class FileLogbookRepository: LogbookRepository {
return logbooksNames
}
override fun renameLogbook(
context: Context,
oldName: String,
newName: String,
listener: LogbookRenamedListener
) {
val oldFile = File(context.filesDir, getFileName(oldName))
val newFile = File(context.filesDir, getFileName(newName))
if (newFile.exists()) {
listener.onIOError(okio.IOException("A logbook with this name already exists"))
return
}
if (oldFile.renameTo(newFile)) {
listener.onLogbookRenamed()
} else {
listener.onIOError(okio.IOException("Failed to rename logbook"))
}
}
override fun deleteLogbook(
context: Context,
name: String,
listener: LogbookDeletedListener
) {
val file = File(context.filesDir, getFileName(name))
if (file.delete()) {
listener.onLogbookDeleted()
} else {
listener.onIOError(okio.IOException("Failed to delete logbook"))
}
}
private fun getFileName(name: String): String {
return "$FILE_NAME_START${if (name.isNotEmpty()) "_" else ""}${name}$FILE_NAME_END"
}

View File

@@ -14,6 +14,7 @@ class LocalSettingsRepository(val context: Context) {
const val SHARED_PREFS_DAV_USER = "webdav_user"
const val SHARED_PREFS_DAV_PASS = "webdav_password"
const val SHARED_PREFS_NO_BREASTFEEDING = "no_breastfeeding"
const val SHARED_PREFS_NO_BOTTLE = "no_bottle"
const val SHARED_PREFS_SIGNATURE = "signature"
const val SHARED_PREFS_BF_TIMER_START = "bf_timer_start"
const val SHARED_PREFS_BF_TIMER_TYPE = "bf_timer_type"
@@ -50,6 +51,14 @@ class LocalSettingsRepository(val context: Context) {
return sharedPreferences.getBoolean(SHARED_PREFS_NO_BREASTFEEDING, false)
}
fun saveNoBottle(content: Boolean) {
sharedPreferences.edit { putBoolean(SHARED_PREFS_NO_BOTTLE, content) }
}
fun loadNoBottle(): Boolean {
return sharedPreferences.getBoolean(SHARED_PREFS_NO_BOTTLE, false)
}
fun saveDataRepository(repo: DATA_REPO) {
sharedPreferences.edit(commit = true) {
putString(

View File

@@ -13,6 +13,8 @@ interface LogbookRepository {
fun loadLogbook(context: Context, name: String = "", listener: LogbookLoadedListener)
fun saveLogbook(context: Context,logbook: Logbook, listener: LogbookSavedListener)
fun listLogbooks(context: Context, listener: LogbookListObtainedListener)
fun renameLogbook(context: Context, oldName: String, newName: String, listener: LogbookRenamedListener)
fun deleteLogbook(context: Context, name: String, listener: LogbookDeletedListener)
}
interface LogbookLoadedListener {
@@ -37,3 +39,17 @@ interface LogbookListObtainedListener {
fun onWebDAVError(error: SardineException)
fun onError(error: Exception)
}
interface LogbookRenamedListener {
fun onLogbookRenamed()
fun onIOError(error: IOException)
fun onWebDAVError(error: SardineException)
fun onError(error: Exception)
}
interface LogbookDeletedListener {
fun onLogbookDeleted()
fun onIOError(error: IOException)
fun onWebDAVError(error: SardineException)
fun onError(error: Exception)
}

View File

@@ -133,21 +133,125 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
}
private fun saveLogbook(context: Context, logbook: Logbook) {
// Lock logbook on WebDAV to avoid concurrent changes
//sardine.lock(getUrl())
// Reload logbook from WebDAV
// Merge logbooks (based on time)
// Write logbook
// Unlock logbook on WebDAV
//sardine.unlock(getUrl())
// Take a snapshot of local events (UI thread may modify logbook.logs concurrently)
val localSnapshot = ArrayList(logbook.logs)
val removedFingerprints = HashSet(logbook.removedSinceLoad)
// Load remote logbook and merge before saving to avoid overwriting
// events added by other devices
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()
for (l in logbook.logs) {
for (l in mergedEvents) {
ja.put(l.toJson())
}
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.
* If it does not exist, try to upload the local one.
@@ -192,6 +296,62 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
}
}
override fun renameLogbook(
context: Context,
oldName: String,
newName: String,
listener: LogbookRenamedListener
) {
Thread(Runnable {
try {
val oldUrl = getUrl(oldName)
val newUrl = getUrl(newName)
// Check if target already exists
try {
sardine.get(newUrl).close()
listener.onIOError(IOException("A logbook with this name already exists"))
return@Runnable
} catch (e: SardineException) {
// 404 is expected - file doesn't exist, we can proceed
if (!e.toString().contains("404")) {
throw e
}
}
sardine.move(oldUrl, newUrl)
listener.onLogbookRenamed()
} catch (e: SardineException) {
Log.e(TAG, e.toString())
listener.onWebDAVError(e)
} catch (e: IOException) {
Log.e(TAG, e.toString())
listener.onIOError(e)
} catch (e: Exception) {
listener.onError(e)
}
}).start()
}
override fun deleteLogbook(
context: Context,
name: String,
listener: LogbookDeletedListener
) {
Thread(Runnable {
try {
sardine.delete(getUrl(name))
listener.onLogbookDeleted()
} catch (e: SardineException) {
Log.e(TAG, e.toString())
listener.onWebDAVError(e)
} catch (e: IOException) {
Log.e(TAG, e.toString())
listener.onIOError(e)
} catch (e: Exception) {
listener.onError(e)
}
}).start()
}
private fun getUrl(name: String): String {
val fileName = "${FILE_NAME_START}${if (name.isNotEmpty()) "_" else ""}${name}${FILE_NAME_END}"
Log.d(TAG, fileName)

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

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#888888"
android:pathData="M11,18c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2 0.9,-2 2,-2 2,0.9 2,2zM9,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM15,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="@color/accent"/>
<stroke android:width="2dp" android:color="#B8A050"/>
<corners android:radius="4dp"/>
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FFFFFF"/>
<stroke android:width="1dp" android:color="#999999"/>
<corners android:radius="4dp"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_backup_title"
android:textSize="28sp"
android:textColor="@color/accent"/>
<Button
android:id="@+id/backup_export"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/settings_export"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_export_desc"/>
<Button
android:id="@+id/backup_import"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/settings_import"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_import_desc"/>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:indicatorColor="@color/accent"
android:layout_marginTop="20dp"
android:visibility="invisible"/>
<Button
android:id="@+id/backup_close"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@android:string/ok"/>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/button_config_title"
android:textSize="28sp"
android:textColor="@color/accent"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/button_config_hint"
android:textColor="@android:color/darker_gray"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/button_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="20dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/btn_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:background="@drawable/button_background"
android:textColor="@android:color/darker_gray"
android:text="@string/button_config_cancel"/>
<Button
android:id="@+id/btn_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/button_config_save"/>
</LinearLayout>
</LinearLayout>

View File

@@ -73,6 +73,16 @@
</FrameLayout>
<ImageView
android:id="@+id/logbooks_edit_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:padding="8dp"
android:src="@drawable/ic_edit"
android:background="@drawable/button_background"
app:tint="@color/accent"/>
<TextView
android:id="@+id/logbooks_add_button"
android:layout_width="wrap_content"
@@ -88,121 +98,15 @@
</LinearLayout>
<LinearLayout
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/buttons_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/button_bottle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="2"
android:layout_margin="5dp"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="50sp"
android:text="@string/event_bottle_type"/>
<TextView
android:id="@+id/button_food"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="5dp"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="50sp"
android:text="@string/event_food_type"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/layout_nipples">
<TextView
android:id="@+id/button_nipple_left"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_weight="1"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="30sp"
android:text="🤱⬅️"/>
<TextView
android:id="@+id/button_nipple_both"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_weight="1"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="30sp"
android:text="🤱↔️"/>
<TextView
android:id="@+id/button_nipple_right"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_weight="1"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="30sp"
android:text="🤱➡️️"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/button_change_poo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_weight="2"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="30sp"
android:text="🚼 💩"/>
<TextView
android:id="@+id/button_change_pee"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_weight="2"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:textSize="30sp"
android:text="🚼 💧"/>
<ImageView
android:id="@+id/button_more"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="5dp"
android:layout_weight="1"
android:background="@drawable/button_background"
android:gravity="center_horizontal"
android:src="@drawable/ic_more"
app:tint="@android:color/darker_gray"/>
</LinearLayout>
</LinearLayout>
app:flexWrap="wrap"
app:justifyContent="flex_start"
app:alignItems="stretch">
<!-- Buttons werden dynamisch hinzugefügt -->
</com.google.android.flexbox.FlexboxLayout>
<LinearLayout
android:layout_width="match_parent"

View File

@@ -1,240 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
android:orientation="vertical"
android:padding="20dp">
<LinearLayout
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
android:text="@string/settings_title"
android:textSize="28sp"
android:textColor="@color/accent"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textSize="28sp"
android:textColor="@color/accent"/>
<!-- Storage Settings Button -->
<Button
android:id="@+id/settings_storage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/storage_settings_title"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:layout_marginTop="20dp"
android:text="@string/settings_storage"/>
<TextView
android:id="@+id/settings_storage_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_storage_status_local"/>
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Button Configuration -->
<Button
android:id="@+id/settings_button_config"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/button_config_title"/>
<RadioButton android:id="@+id/settings_data_local"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:checked="true"
android:text="@string/settings_storage_local"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/button_config_settings_desc"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_storage_local_desc"/>
<!-- Data Backup Button -->
<Button
android:id="@+id/settings_backup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/settings_backup_title"/>
<RadioButton android:id="@+id/settings_data_webdav"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/settings_storage_dav"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_backup_desc"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_storage_dav_desc"/>
<!-- Spacer to push close button to bottom -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_url"/>
<Button
android:id="@+id/settings_close"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/close"/>
<EditText
android:id="@+id/settings_data_webdav_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:hint="@string/settings_storage_dav_url_hint"
android:inputType="textUri"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_user"/>
<EditText
android:id="@+id/settings_data_webdav_user"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:inputType="textEmailAddress"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_pass"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/settings_data_webdav_pass"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/textview_background"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:indicatorColor="@color/accent"
android:layout_marginTop="20dp"
android:visibility="invisible"/>
</RadioGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="@string/settings_signature" />
<EditText
android:id="@+id/settings_signature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="5dp"
android:inputType="textEmailAddress"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_signature_desc"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="20dp">
<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="@string/settings_no_breastfeeding" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_no_breastfeeding"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="20dp"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_no_breastfeeding_desc"/>
<!-- Data Backup Section -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:layout_marginTop="30dp"
android:text="@string/settings_backup_title"/>
<Button
android:id="@+id/settings_export"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/settings_export"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_export_desc"/>
<Button
android:id="@+id/settings_import"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@string/settings_import"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_import_desc"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal">
<Button
android:id="@+id/settings_cancel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@android:string/cancel"/>
<Button
android:id="@+id/settings_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/accent"
android:background="@drawable/button_background"
android:text="@android:string/ok"/>
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/storage_settings_title"
android:textSize="28sp"
android:textColor="@color/accent"/>
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical">
<RadioButton android:id="@+id/settings_data_local"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/settings_storage_local"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_storage_local_desc"/>
<RadioButton android:id="@+id/settings_data_webdav"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/settings_storage_dav"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="@string/settings_storage_dav_desc"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_url"/>
<EditText
android:id="@+id/settings_data_webdav_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:hint="@string/settings_storage_dav_url_hint"
android:inputType="textUri"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_user"/>
<EditText
android:id="@+id/settings_data_webdav_user"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:inputType="textEmailAddress"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="30dp"
android:textStyle="bold"
android:text="@string/settings_storage_dav_pass"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/settings_data_webdav_pass"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/textview_background"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:indicatorColor="@color/accent"
android:layout_marginTop="20dp"
android:visibility="invisible"/>
</RadioGroup>
<!-- Signature -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:textStyle="bold"
android:text="@string/settings_signature" />
<EditText
android:id="@+id/settings_signature"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="5dp"
android:inputType="textEmailAddress"
android:background="@drawable/textview_background"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_marginTop="5dp"
android:text="@string/settings_signature_desc"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:orientation="horizontal">
<Button
android:id="@+id/settings_cancel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="20dp"
android:background="@drawable/button_background"
android:textColor="@color/accent"
android:text="@android:string/cancel"/>
<Button
android:id="@+id/settings_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/accent"
android:background="@drawable/button_background"
android:text="@android:string/ok"/>
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp">
<EditText
android:id="@+id/dialog_edit_logbook_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lines="1"
android:inputType="text"
android:hint="@string/edit_logbook_name_hint"
android:background="@drawable/textview_background"/>
<Button
android:id="@+id/dialog_edit_logbook_delete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/button_background"
android:textColor="#FF5252"
android:text="@string/delete_logbook"/>
</LinearLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/drag_handle"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_drag"
android:contentDescription="@string/button_config_drag_hint"/>
<CheckBox
android:id="@+id/checkbox_visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"/>
<TextView
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="24sp"/>
<TextView
android:id="@+id/label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:textSize="16sp"/>
<!-- Size buttons S/M/L -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/size_small"
android:layout_width="28dp"
android:layout_height="28dp"
android:text="S"
android:textSize="12sp"
android:textStyle="bold"
android:gravity="center"
android:background="@drawable/size_button_background"/>
<TextView
android:id="@+id/size_medium"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginStart="4dp"
android:text="M"
android:textSize="12sp"
android:textStyle="bold"
android:gravity="center"
android:background="@drawable/size_button_background"/>
<TextView
android:id="@+id/size_large"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginStart="4dp"
android:text="L"
android:textSize="12sp"
android:textStyle="bold"
android:gravity="center"
android:background="@drawable/size_button_background"/>
</LinearLayout>
</LinearLayout>

View File

@@ -25,6 +25,7 @@
<string name="event_note_desc">Notiz</string>
<string name="event_temperature_desc">Temperatur</string>
<string name="event_colic_desc">Blähungskolik</string>
<string name="event_more_desc">Mehr</string>
<string name="event_unknown_desc"></string>
<string name="overflow_event_scale">⚖️ Gewicht</string>
@@ -51,7 +52,12 @@
<string name="no_connection_retry">Erneut versuchen</string>
<string name="settings_title">Einstellungen</string>
<string name="close">Schließen</string>
<string name="storage_settings_title">Speicherort</string>
<string name="settings_storage_status_local">Aktuell: Lokaler Speicher</string>
<string name="settings_storage_status_webdav">Aktuell: WebDAV</string>
<string name="settings_no_breastfeeding">Kein Stillen</string>
<string name="settings_no_bottle">Kein Fläschchen</string>
<string name="settings_storage">Speicherort für Daten auswählen</string>
<string name="settings_storage_local">Auf dem Gerät</string>
<string name="settings_storage_local_desc">Datenschutzfreundlichste Lösung: Deine Daten verlassen dein Gerät nicht</string>
@@ -98,6 +104,7 @@
<string name="breastfeeding_timer_stop">Stopp</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="another_timer_already_running">Es läuft bereits ein anderer Timer</string>
<string name="breastfeeding_duration_title">Stilldauer</string>
<string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string>
@@ -126,6 +133,7 @@
<string name="settings_signature">Signatur</string>
<string name="settings_signature_desc">Füge jedem Event eine Signatur hinzu, die andere sehen können. Nützlich wenn mehrere Personen Events hinzufügen.</string>
<string name="settings_no_breastfeeding_desc">Verstecke die Stillbuttons wenn sie nicht benötigt werden.</string>
<string name="settings_no_bottle_desc">Verstecke den Fläschchen-Button wenn er nicht benötigt wird.</string>
<!-- Event-Detail-Dialog -->
<string name="dialog_event_detail_quantity">Menge</string>
@@ -203,8 +211,23 @@
<string name="stats_weight_format">%.2f kg</string>
<string name="stats_temperature_format">%.1f °C</string>
<!-- Button Configuration -->
<string name="button_config_title">Button-Konfiguration</string>
<string name="button_config_hint">Ziehen zum Sortieren, ankreuzen für Hauptbildschirm</string>
<string name="button_config_settings_desc">Anpassen welche Buttons auf dem Hauptbildschirm erscheinen</string>
<string name="button_config_save">Speichern</string>
<string name="button_config_cancel">Abbrechen</string>
<string name="button_config_saved">Button-Konfiguration gespeichert</string>
<string name="button_config_drag_hint">Ziehen zum Sortieren</string>
<string name="button_size_label">Größe:</string>
<string name="button_size_small">S</string>
<string name="button_size_medium">M</string>
<string name="button_size_large">L</string>
<!-- Export/Import -->
<string name="backup_title">Datensicherung</string>
<string name="settings_backup_title">Datensicherung</string>
<string name="settings_backup_desc">Logbook-Daten exportieren oder importieren</string>
<string name="settings_export">Logbook exportieren</string>
<string name="settings_export_desc">Alle Ereignisse als JSON-Datei speichern</string>
<string name="settings_import">Logbook importieren</string>
@@ -214,4 +237,23 @@
<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>
<string name="rename_logbook">Umbenennen</string>
<string name="delete_logbook">Logbook löschen</string>
<string name="delete_logbook_confirm">Logbook \"%s\" löschen? Alle Ereignisse gehen verloren. Dies kann nicht rückgängig gemacht werden.</string>
<string name="logbook_renamed">Logbook umbenannt</string>
<string name="logbook_deleted">Logbook gelöscht</string>
<string name="logbook_rename_error">Umbenennen fehlgeschlagen</string>
<string name="logbook_delete_error">Löschen fehlgeschlagen</string>
<string name="logbook_name_exists">Ein Logbook mit diesem Namen existiert bereits</string>
</resources>

View File

@@ -25,6 +25,7 @@
<string name="event_note_desc">Note</string>
<string name="event_temperature_desc">Température</string>
<string name="event_colic_desc">Colique gazeuse</string>
<string name="event_more_desc">Plus</string>
<string name="event_unknown_desc"></string>
<string name="overflow_event_scale">⚖️ Poids</string>
@@ -51,6 +52,10 @@
<string name="no_connection_retry">Réessayer</string>
<string name="settings_title">Paramètres</string>
<string name="close">Fermer</string>
<string name="storage_settings_title">Stockage</string>
<string name="settings_storage_status_local">Actuellement : Stockage local</string>
<string name="settings_storage_status_webdav">Actuellement : WebDAV</string>
<string name="settings_storage">Choisir le lieu de stockage des données</string>
<string name="settings_storage_local">Sur l\'appareil\'</string>
<string name="settings_storage_local_desc">La solution la plus respectueuse de la vie privée : les données ne quittent pas l\'appareil</string>
@@ -69,6 +74,8 @@
<string name="settings_json_error">Il y a un fichier sur le serveur WebDAV, toutefois il est corronpu ou illisible. Merci de le supprimer et réessayer</string>
<string name="settings_generic_error">Erreur: </string>
<string name="settings_webdav_upload_error">Une erreur est survenue en téléversant le journal local %1$s sur %2$s</string>
<string name="settings_no_bottle">Pas de biberon</string>
<string name="settings_no_bottle_desc">Masquer le bouton biberon s\'il n\'est pas nécessaire.</string>
<string name="trim_logbook_dialog_title">Votre journal grossit !</string>
<string name="trim_logbook_dialog_message_local">Le fichier de votre journal a beaucoup grossi. Nous recommandons de supprimer les entrées les plus vieilles pour éviter des crashs de l\'application.</string>
@@ -97,6 +104,7 @@
<string name="breastfeeding_timer_stop">Arrêter</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="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_description">Entrez la durée en minutes</string>
@@ -171,8 +179,23 @@
<string name="stats_weight_format">%.2f kg</string>
<string name="stats_temperature_format">%.1f °C</string>
<!-- Button Configuration -->
<string name="button_config_title">Configuration des boutons</string>
<string name="button_config_hint">Glisser pour réorganiser, cocher pour afficher sur l\'écran principal</string>
<string name="button_config_settings_desc">Personnaliser les boutons affichés sur l\'écran principal</string>
<string name="button_config_save">Enregistrer</string>
<string name="button_config_cancel">Annuler</string>
<string name="button_config_saved">Configuration des boutons enregistrée</string>
<string name="button_config_drag_hint">Glisser pour réorganiser</string>
<string name="button_size_label">Taille :</string>
<string name="button_size_small">S</string>
<string name="button_size_medium">M</string>
<string name="button_size_large">L</string>
<!-- Export/Import -->
<string name="backup_title">Sauvegarde des données</string>
<string name="settings_backup_title">Sauvegarde des données</string>
<string name="settings_backup_desc">Exporter ou importer les données du journal</string>
<string name="settings_export">Exporter le journal</string>
<string name="settings_export_desc">Enregistrer tous les événements en fichier JSON</string>
<string name="settings_import">Importer un journal</string>
@@ -182,4 +205,23 @@
<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>
<string name="rename_logbook">Renommer</string>
<string name="delete_logbook">Supprimer le journal</string>
<string name="delete_logbook_confirm">Supprimer le journal \"%s\" ? Tous les événements seront perdus. Cette action est irréversible.</string>
<string name="logbook_renamed">Journal renommé</string>
<string name="logbook_deleted">Journal supprimé</string>
<string name="logbook_rename_error">Échec du renommage</string>
<string name="logbook_delete_error">Échec de la suppression</string>
<string name="logbook_name_exists">Un journal avec ce nom existe déjà</string>
</resources>

View File

@@ -32,6 +32,7 @@
<string name="event_note_desc">Nota</string>
<string name="event_temperature_desc">Temperatura</string>
<string name="event_colic_desc">Colichette</string>
<string name="event_more_desc">Altro</string>
<string name="event_unknown_desc"></string>
<string name="toast_event_added">Evento aggiunto</string>
@@ -51,6 +52,10 @@
<string name="no_connection_retry">Riprova</string>
<string name="settings_title">Impostazioni</string>
<string name="close">Chiudi</string>
<string name="storage_settings_title">Archiviazione</string>
<string name="settings_storage_status_local">Attualmente: Archiviazione locale</string>
<string name="settings_storage_status_webdav">Attualmente: WebDAV</string>
<string name="settings_storage">Scegli dove l\'app salva i dati</string>
<string name="settings_storage_local">Sul dispositivo</string>
<string name="settings_storage_local_desc">La soluzione più privacy-friendly: i dati non escono mai dal tuo dispositivo</string>
@@ -69,6 +74,8 @@
<string name="settings_json_error">Sul server esiste un salvataggio, ma è corrotto o illeggibile. Cancellare il file </string>
<string name="settings_generic_error">Si è verificato un errore: </string>
<string name="settings_webdav_upload_error">Errore durante l\'upload del logbook locale %1$s su webdav: %2$s</string>
<string name="settings_no_bottle">Niente biberon</string>
<string name="settings_no_bottle_desc">Nascondi il pulsante biberon quando non è necessario.</string>
<string name="trim_logbook_dialog_title">Il tuo diario è bello grande!</string>
<string name="trim_logbook_dialog_message_local">Il file del tuo diario sta crescendo molto. Ti suggeriamo di cancellare gli eventi più vecchi per evitare problemi di memoria.</string>
@@ -97,6 +104,7 @@
<string name="breastfeeding_timer_stop">Stop</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="another_timer_already_running">Un altro timer è già in corso</string>
<string name="breastfeeding_duration_title">Durata allattamento</string>
<string name="breastfeeding_duration_description">Inserisci la durata in minuti</string>
@@ -171,8 +179,23 @@
<string name="stats_weight_format">%.2f kg</string>
<string name="stats_temperature_format">%.1f °C</string>
<!-- Button Configuration -->
<string name="button_config_title">Configurazione pulsanti</string>
<string name="button_config_hint">Trascina per riordinare, seleziona per mostrare sulla schermata principale</string>
<string name="button_config_settings_desc">Personalizza quali pulsanti appaiono sulla schermata principale</string>
<string name="button_config_save">Salva</string>
<string name="button_config_cancel">Annulla</string>
<string name="button_config_saved">Configurazione pulsanti salvata</string>
<string name="button_config_drag_hint">Trascina per riordinare</string>
<string name="button_size_label">Dimensione:</string>
<string name="button_size_small">S</string>
<string name="button_size_medium">M</string>
<string name="button_size_large">L</string>
<!-- Export/Import -->
<string name="backup_title">Backup dati</string>
<string name="settings_backup_title">Backup dati</string>
<string name="settings_backup_desc">Esporta o importa i dati del diario</string>
<string name="settings_export">Esporta diario</string>
<string name="settings_export_desc">Salva tutti gli eventi come file JSON</string>
<string name="settings_import">Importa diario</string>
@@ -182,4 +205,23 @@
<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>
<string name="rename_logbook">Rinomina</string>
<string name="delete_logbook">Elimina diario</string>
<string name="delete_logbook_confirm">Eliminare il diario \"%s\"? Tutti gli eventi andranno persi. Questa azione non può essere annullata.</string>
<string name="logbook_renamed">Diario rinominato</string>
<string name="logbook_deleted">Diario eliminato</string>
<string name="logbook_rename_error">Rinomina fallita</string>
<string name="logbook_delete_error">Eliminazione fallita</string>
<string name="logbook_name_exists">Un diario con questo nome esiste già</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Button dimensions for dynamic button layout -->
<dimen name="button_margin">5dp</dimen>
<dimen name="button_min_width">80dp</dimen>
<dimen name="button_padding">8dp</dimen>
<!-- Button sizes: Small -->
<dimen name="button_text_size_small">24sp</dimen>
<dimen name="button_padding_small">6dp</dimen>
<dimen name="button_min_width_small">60dp</dimen>
<!-- Button sizes: Medium -->
<dimen name="button_text_size_medium">30sp</dimen>
<dimen name="button_padding_medium">8dp</dimen>
<dimen name="button_min_width_medium">80dp</dimen>
<!-- Button sizes: Large -->
<dimen name="button_text_size_large">40sp</dimen>
<dimen name="button_padding_large">12dp</dimen>
<dimen name="button_min_width_large">100dp</dimen>
</resources>

View File

@@ -31,6 +31,7 @@
<string name="event_puke_type" translatable="false">🤮</string>
<string name="event_bath_type" translatable="false">🛁</string>
<string name="event_sleep_type" translatable="false">🌙</string>
<string name="event_more_type" translatable="false"></string>
<string name="event_unknown_type" translatable="false">\?</string>
<string name="event_bottle_desc">Baby bottle</string>
@@ -49,6 +50,7 @@
<string name="event_puke_desc">Puke</string>
<string name="event_bath_desc">Bath</string>
<string name="event_sleep_desc">Sleep</string>
<string name="event_more_desc">More</string>
<string name="event_unknown_desc"></string>
<string name="overflow_event_scale">⚖️ Weight</string>
@@ -87,6 +89,10 @@
<string name="no_connection_retry">Retry</string>
<string name="settings_title">Settings</string>
<string name="close">Close</string>
<string name="storage_settings_title">Storage Settings</string>
<string name="settings_storage_status_local">Currently: Local storage</string>
<string name="settings_storage_status_webdav">Currently: WebDAV</string>
<string name="settings_signature">Signature</string>
<string name="settings_signature_desc">Attach a signature to each event you create and for others to see. Useful if multiple people add events.</string>
<string name="settings_storage">Choose where to save data</string>
@@ -106,6 +112,8 @@
<string name="settings_webdav_creation_ok">Successfully connected with the WebDAV server</string>
<string name="settings_no_breastfeeding">No Breastfeeding</string>
<string name="settings_no_breastfeeding_desc">Hide the Breastfeeding buttons for when they are not needed.</string>
<string name="settings_no_bottle">No Bottle-Feeding</string>
<string name="settings_no_bottle_desc">Hide the Bottle-Feeding button for when it is not needed.</string>
<string name="settings_json_error">There\'s a save file on the server, but it is corrupted or unreadable. Please delete it </string>
<string name="settings_generic_error">Error: </string>
<string name="settings_webdav_upload_error">Error while uploading local logbook %1$s to webdav: %2$s</string>
@@ -154,6 +162,7 @@
<string name="breastfeeding_timer_stop">Stop</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="another_timer_already_running">Another timer is already running</string>
<string name="breastfeeding_duration_title">Breastfeeding duration</string>
<string name="breastfeeding_duration_description">Enter the duration in minutes</string>
<string name="measurement_unit_time_minutes" translatable="false">min</string>
@@ -228,8 +237,23 @@
<string name="stats_weight_format">%.2f kg</string>
<string name="stats_temperature_format">%.1f °C</string>
<!-- Button Configuration -->
<string name="button_config_title">Button Configuration</string>
<string name="button_config_hint">Drag to reorder, check to show on main screen</string>
<string name="button_config_settings_desc">Customize which buttons appear on the main screen</string>
<string name="button_config_save">Save</string>
<string name="button_config_cancel">Cancel</string>
<string name="button_config_saved">Button configuration saved</string>
<string name="button_config_drag_hint">Drag to reorder</string>
<string name="button_size_label">Size:</string>
<string name="button_size_small">S</string>
<string name="button_size_medium">M</string>
<string name="button_size_large">L</string>
<!-- Export/Import -->
<string name="backup_title">Data Backup</string>
<string name="settings_backup_title">Data Backup</string>
<string name="settings_backup_desc">Export or import logbook data</string>
<string name="settings_export">Export Logbook</string>
<string name="settings_export_desc">Save all events as JSON file</string>
<string name="settings_import">Import Logbook</string>
@@ -239,4 +263,23 @@
<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>
<string name="rename_logbook">Rename</string>
<string name="delete_logbook">Delete Logbook</string>
<string name="delete_logbook_confirm">Delete logbook \"%s\"? All events will be lost. This cannot be undone.</string>
<string name="logbook_renamed">Logbook renamed</string>
<string name="logbook_deleted">Logbook deleted</string>
<string name="logbook_rename_error">Failed to rename logbook</string>
<string name="logbook_delete_error">Failed to delete logbook</string>
<string name="logbook_name_exists">A logbook with this name already exists</string>
</resources>