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
This commit is contained in:
2026-01-08 09:33:36 +01:00
parent dccc89a8e2
commit b6110c2cbb
32 changed files with 2841 additions and 9 deletions

View File

@@ -76,6 +76,12 @@ class MainActivity : AppCompatActivity() {
var bfTimerHandler: Handler? = null
var bfTimerRunnable: Runnable? = null
// Sleep timer state
var sleepTimerStartTime: Long = 0
var sleepTimerDialog: AlertDialog? = null
var sleepTimerHandler: Handler? = null
var sleepTimerRunnable: Runnable? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -134,6 +140,9 @@ class MainActivity : AppCompatActivity() {
findViewById<View>(R.id.button_settings).setOnClickListener {
showSettings()
}
findViewById<View>(R.id.button_statistics).setOnClickListener {
showStatistics()
}
findViewById<View>(R.id.button_no_connection_retry).setOnClickListener {
// This may happen at start, when logbook is still null: better ask the logbook list
loadLogbookList()
@@ -164,6 +173,12 @@ class MainActivity : AppCompatActivity() {
startActivity(i)
}
fun showStatistics() {
val i = Intent(this, StatisticsActivity::class.java)
i.putExtra(StatisticsActivity.EXTRA_LOGBOOK_NAME, logbook?.name ?: "")
startActivity(i)
}
fun showLogbook() {
// Show logbook
if (logbook == null)
@@ -204,6 +219,9 @@ class MainActivity : AppCompatActivity() {
// Check for ongoing breastfeeding timer
restoreBreastfeedingTimerIfNeeded()
// Check for ongoing sleep timer
restoreSleepTimerIfNeeded()
if (logbook != null) {
// Already running: reload data for currently selected logbook
loadLogbook(logbook!!.name)
@@ -220,6 +238,10 @@ class MainActivity : AppCompatActivity() {
bfTimerRunnable?.let { bfTimerHandler?.removeCallbacks(it) }
bfTimerDialog?.dismiss()
// Clean up sleep timer UI (state is preserved in SharedPreferences)
sleepTimerRunnable?.let { sleepTimerHandler?.removeCallbacks(it) }
sleepTimerDialog?.dismiss()
super.onStop()
}
@@ -452,6 +474,117 @@ class MainActivity : AppCompatActivity() {
}
}
// Sleep timer methods
fun startSleepTimer() {
// Check if timer already running
if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Save timer state
sleepTimerStartTime = System.currentTimeMillis()
saveSleepTimerState()
// Show timer dialog
showSleepTimerDialog()
}
fun showSleepTimerDialog() {
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.sleep_timer_dialog, null)
d.setTitle(R.string.sleep_timer_title)
d.setView(dialogView)
d.setCancelable(false)
val timerDisplay = dialogView.findViewById<TextView>(R.id.sleep_timer_display)
// Set up timer updates
sleepTimerHandler = Handler(mainLooper)
sleepTimerRunnable = object : Runnable {
override fun run() {
val elapsed = (System.currentTimeMillis() - sleepTimerStartTime) / 1000
val hours = elapsed / 3600
val minutes = (elapsed % 3600) / 60
val seconds = elapsed % 60
timerDisplay.text = if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
sleepTimerHandler?.postDelayed(this, 1000)
}
}
sleepTimerHandler?.post(sleepTimerRunnable!!)
d.setPositiveButton(R.string.sleep_timer_stop) { _, _ ->
stopSleepTimer()
}
d.setNegativeButton(android.R.string.cancel) { dialogInterface, _ ->
cancelSleepTimer()
dialogInterface.dismiss()
}
sleepTimerDialog = d.create()
sleepTimerDialog?.show()
}
fun stopSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
val durationMillis = System.currentTimeMillis() - sleepTimerStartTime
val durationMinutes = Math.max(1, (durationMillis / 60000).toInt()) // Minimum 1 minute
clearSleepTimerState()
logEvent(LunaEvent(LunaEvent.TYPE_SLEEP, durationMinutes))
}
fun cancelSleepTimer() {
sleepTimerHandler?.removeCallbacks(sleepTimerRunnable!!)
clearSleepTimerState()
}
fun askSleepDuration() {
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
d.setTitle(R.string.sleep_duration_title)
d.setMessage(R.string.sleep_duration_description)
d.setView(dialogView)
val numberPicker = dialogView.findViewById<NumberPicker>(R.id.sleep_duration_picker)
numberPicker.minValue = 1
numberPicker.maxValue = 180 // Up to 3 hours
numberPicker.value = 30 // Default 30 minutes
numberPicker.wrapSelectorWheel = false
d.setPositiveButton(android.R.string.ok) { _, _ ->
logEvent(LunaEvent(LunaEvent.TYPE_SLEEP, numberPicker.value))
}
d.setNegativeButton(android.R.string.cancel) { dialogInterface, _ ->
dialogInterface.dismiss()
}
d.create().show()
}
fun saveSleepTimerState() {
LocalSettingsRepository(this).saveSleepTimer(sleepTimerStartTime)
}
fun clearSleepTimerState() {
sleepTimerStartTime = 0
sleepTimerDialog = null
LocalSettingsRepository(this).clearSleepTimer()
}
fun restoreSleepTimerIfNeeded() {
val startTime = LocalSettingsRepository(this).loadSleepTimer()
if (startTime > 0) {
sleepTimerStartTime = startTime
showSleepTimerDialog()
}
}
fun askToTrimLogbook() {
val d = AlertDialog.Builder(this)
d.setTitle(R.string.trim_logbook_dialog_title)
@@ -539,24 +672,32 @@ class MainActivity : AppCompatActivity() {
}, startYear, startMonth, startDay).show()
}
// Make quantity editable for breastfeeding events
// Make quantity editable for breastfeeding and sleep events
val quantityTextView = dialogView.findViewById<TextView>(R.id.dialog_event_detail_type_quantity)
if (event.type in listOf(
val isBreastfeeding = event.type in listOf(
LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE,
LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE,
LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE
) && event.quantity > 0) {
)
val isSleep = event.type == LunaEvent.TYPE_SLEEP
if ((isBreastfeeding || isSleep) && event.quantity > 0) {
quantityTextView.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_edit, 0)
quantityTextView.compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.accent))
quantityTextView.setOnClickListener {
val pickerDialog = AlertDialog.Builder(this@MainActivity)
val pickerView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
val picker = pickerView.findViewById<NumberPicker>(R.id.breastfeeding_duration_picker)
val pickerView = if (isSleep) {
layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
} else {
layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
}
val picker = pickerView.findViewById<NumberPicker>(
if (isSleep) R.id.sleep_duration_picker else R.id.breastfeeding_duration_picker
)
picker.minValue = 1
picker.maxValue = 60
picker.value = if (event.quantity > 0) event.quantity else 15
picker.maxValue = if (isSleep) 180 else 60
picker.value = if (event.quantity > 0) Math.min(event.quantity, picker.maxValue) else if (isSleep) 30 else 15
pickerDialog.setTitle(R.string.breastfeeding_duration_title)
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
@@ -971,6 +1112,15 @@ class MainActivity : AppCompatActivity() {
isOutsideTouchable = true
val inflater = LayoutInflater.from(anchor.context)
contentView = inflater.inflate(R.layout.more_events_popup, null)
contentView.findViewById<View>(R.id.button_sleep).setOnClickListener {
startSleepTimer()
dismiss()
}
contentView.findViewById<View>(R.id.button_sleep).setOnLongClickListener {
askSleepDuration()
dismiss()
true
}
contentView.findViewById<View>(R.id.button_medicine).setOnClickListener {
askNotes(LunaEvent(LunaEvent.TYPE_MEDICINE))
dismiss()

View File

@@ -1,20 +1,26 @@
package it.danieleverducci.lunatracker
import android.net.Uri
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
@@ -27,6 +33,15 @@ open class SettingsActivity : AppCompatActivity() {
protected lateinit var switchNoBreastfeeding: SwitchMaterial
protected lateinit var textViewSignature: EditText
// Activity Result Launchers for Export/Import
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)
@@ -46,6 +61,12 @@ open class SettingsActivity : AppCompatActivity() {
findViewById<View>(R.id.settings_cancel).setOnClickListener({
finish()
})
findViewById<View>(R.id.settings_export).setOnClickListener({
startExport()
})
findViewById<View>(R.id.settings_import).setOnClickListener({
startImport()
})
settingsRepository = LocalSettingsRepository(this)
loadSettings()
@@ -198,4 +219,136 @@ open class SettingsActivity : AppCompatActivity() {
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,207 @@
package it.danieleverducci.lunatracker
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.fragments.DailySummaryFragment
import it.danieleverducci.lunatracker.fragments.DiaperStatsFragment
import it.danieleverducci.lunatracker.fragments.FeedingStatsFragment
import it.danieleverducci.lunatracker.fragments.GrowthStatsFragment
import it.danieleverducci.lunatracker.fragments.SleepStatsFragment
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookLoadedListener
import it.danieleverducci.lunatracker.repository.LogbookRepository
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import okio.IOException
import org.json.JSONException
import utils.StatisticsCalculator
class StatisticsActivity : AppCompatActivity() {
companion object {
const val TAG = "StatisticsActivity"
const val EXTRA_LOGBOOK_NAME = "logbook_name"
}
private lateinit var viewPager: ViewPager2
private lateinit var tabLayout: TabLayout
private lateinit var periodSpinner: Spinner
private var events: List<LunaEvent> = emptyList()
private var selectedPeriod: Int = 7 // Default 7 days
private var logbookName: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_statistics)
// Back button
findViewById<ImageView>(R.id.button_back).setOnClickListener {
finish()
}
// Title with logbook name
logbookName = intent.getStringExtra(EXTRA_LOGBOOK_NAME) ?: ""
if (logbookName.isNotEmpty()) {
findViewById<TextView>(R.id.statistics_title).text =
"${getString(R.string.statistics_title)} - $logbookName"
}
// Period spinner
periodSpinner = findViewById(R.id.period_spinner)
val periods = arrayOf(
getString(R.string.stats_period_7days),
getString(R.string.stats_period_14days),
getString(R.string.stats_period_30days)
)
val periodAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, periods)
periodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
periodSpinner.adapter = periodAdapter
periodSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedPeriod = when (position) {
0 -> 7
1 -> 14
2 -> 30
else -> 7
}
notifyFragmentsOfPeriodChange()
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
// ViewPager and TabLayout
viewPager = findViewById(R.id.view_pager)
tabLayout = findViewById(R.id.tab_layout)
// Load events
loadEvents()
}
private fun loadEvents() {
val settingsRepo = LocalSettingsRepository(this)
val repository: LogbookRepository = when (settingsRepo.loadDataRepository()) {
LocalSettingsRepository.DATA_REPO.WEBDAV -> {
val credentials = settingsRepo.loadWebdavCredentials()
if (credentials != null) {
WebDAVLogbookRepository(credentials[0], credentials[1], credentials[2])
} else {
FileLogbookRepository()
}
}
LocalSettingsRepository.DATA_REPO.LOCAL_FILE -> FileLogbookRepository()
}
repository.loadLogbook(this, logbookName, object : LogbookLoadedListener {
override fun onLogbookLoaded(logbook: Logbook) {
runOnUiThread {
events = logbook.logs
setupViewPager()
}
}
override fun onIOError(error: IOException) {
Log.e(TAG, "IO error loading logbook", error)
runOnUiThread {
events = emptyList()
setupViewPager()
}
}
override fun onWebDAVError(error: SardineException) {
Log.e(TAG, "WebDAV error loading logbook", error)
runOnUiThread {
events = emptyList()
setupViewPager()
}
}
override fun onJSONError(error: JSONException) {
Log.e(TAG, "JSON error loading logbook", error)
runOnUiThread {
events = emptyList()
setupViewPager()
}
}
override fun onError(error: Exception) {
Log.e(TAG, "Error loading logbook", error)
runOnUiThread {
events = emptyList()
setupViewPager()
}
}
})
}
private fun setupViewPager() {
val adapter = StatisticsPagerAdapter(this)
viewPager.adapter = adapter
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
tab.text = when (position) {
0 -> getString(R.string.stats_tab_today)
1 -> getString(R.string.stats_tab_feeding)
2 -> getString(R.string.stats_tab_diapers)
3 -> getString(R.string.stats_tab_sleep)
4 -> getString(R.string.stats_tab_growth)
else -> ""
}
}.attach()
}
private fun notifyFragmentsOfPeriodChange() {
if (events.isEmpty()) return
// Force fragment refresh by recreating adapter
val currentItem = viewPager.currentItem
viewPager.adapter = StatisticsPagerAdapter(this)
viewPager.setCurrentItem(currentItem, false)
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
tab.text = when (position) {
0 -> getString(R.string.stats_tab_today)
1 -> getString(R.string.stats_tab_feeding)
2 -> getString(R.string.stats_tab_diapers)
3 -> getString(R.string.stats_tab_sleep)
4 -> getString(R.string.stats_tab_growth)
else -> ""
}
}.attach()
}
fun getEvents(): List<LunaEvent> = events
fun getSelectedPeriod(): Int = selectedPeriod
fun getCalculator(): StatisticsCalculator = StatisticsCalculator(events)
private inner class StatisticsPagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
override fun getItemCount(): Int = 5
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> DailySummaryFragment()
1 -> FeedingStatsFragment()
2 -> DiaperStatsFragment()
3 -> SleepStatsFragment()
4 -> GrowthStatsFragment()
else -> DailySummaryFragment()
}
}
}
}

View File

@@ -30,6 +30,7 @@ class LunaEvent: Comparable<LunaEvent> {
const val TYPE_FOOD = "FOOD"
const val TYPE_PUKE = "PUKE"
const val TYPE_BATH = "BATH"
const val TYPE_SLEEP = "SLEEP"
}
private val jo: JSONObject
@@ -100,6 +101,7 @@ class LunaEvent: Comparable<LunaEvent> {
TYPE_FOOD -> R.string.event_food_type
TYPE_PUKE -> R.string.event_puke_type
TYPE_BATH -> R.string.event_bath_type
TYPE_SLEEP -> R.string.event_sleep_type
else -> R.string.event_unknown_type
}
)
@@ -123,6 +125,7 @@ class LunaEvent: Comparable<LunaEvent> {
TYPE_FOOD -> R.string.event_food_desc
TYPE_PUKE -> R.string.event_puke_desc
TYPE_BATH -> R.string.event_bath_desc
TYPE_SLEEP -> R.string.event_sleep_desc
else -> R.string.event_unknown_desc
}
)

View File

@@ -0,0 +1,123 @@
package it.danieleverducci.lunatracker.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.StatisticsActivity
import utils.DailySummary
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class DailySummaryFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_daily_summary, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI(view)
}
private fun updateUI(view: View) {
val activity = activity as? StatisticsActivity ?: return
val calculator = activity.getCalculator()
val period = activity.getSelectedPeriod()
// Get today's summary and average for comparison
val todaySummary = calculator.getTodaySummary()
val feedingStats = calculator.getFeedingStats(period)
val sleepStats = calculator.getSleepStats(period)
val diaperStats = calculator.getDiaperStats(period)
// Date header
val dateFormat = SimpleDateFormat("EEEE, d MMMM", Locale.getDefault())
view.findViewById<TextView>(R.id.date_header).text = dateFormat.format(Date())
// Bottle summary
val bottleSummary = view.findViewById<TextView>(R.id.bottle_summary)
val bottleProgress = view.findViewById<ProgressBar>(R.id.bottle_progress)
val avgBottle = feedingStats.avgBottleMlPerDay.toInt()
bottleSummary.text = "${todaySummary.totalBottleMl} ml (${todaySummary.bottleCount}×) | Ø $avgBottle ml"
if (avgBottle > 0) {
bottleProgress.max = (avgBottle * 1.5).toInt()
bottleProgress.progress = todaySummary.totalBottleMl
}
// Breastfeeding summary
val breastfeedingContainer = view.findViewById<LinearLayout>(R.id.breastfeeding_container)
val breastfeedingSummary = view.findViewById<TextView>(R.id.breastfeeding_summary)
val breastfeedingProgress = view.findViewById<ProgressBar>(R.id.breastfeeding_progress)
if (todaySummary.breastfeedingCount > 0 || feedingStats.avgBreastfeedingMinPerDay > 0) {
breastfeedingContainer.visibility = View.VISIBLE
val avgBf = feedingStats.avgBreastfeedingMinPerDay.toInt()
breastfeedingSummary.text = "${todaySummary.totalBreastfeedingMin} min (${todaySummary.breastfeedingCount}×) | Ø $avgBf min"
if (avgBf > 0) {
breastfeedingProgress.max = (avgBf * 1.5).toInt()
breastfeedingProgress.progress = todaySummary.totalBreastfeedingMin
}
} else {
breastfeedingContainer.visibility = View.GONE
}
// Sleep summary
val sleepSummary = view.findViewById<TextView>(R.id.sleep_summary)
val sleepProgress = view.findViewById<ProgressBar>(R.id.sleep_progress)
val avgSleepMin = sleepStats.avgSleepMinPerDay.toInt()
val todaySleepHours = todaySummary.totalSleepMin / 60f
val avgSleepHours = avgSleepMin / 60f
sleepSummary.text = String.format(Locale.getDefault(), "%.1f h (%d×) | Ø %.1f h",
todaySleepHours, todaySummary.sleepCount, avgSleepHours)
if (avgSleepMin > 0) {
sleepProgress.max = (avgSleepMin * 1.5).toInt()
sleepProgress.progress = todaySummary.totalSleepMin
}
// Diaper summaries
val pooSummary = view.findViewById<TextView>(R.id.poo_summary)
val peeSummary = view.findViewById<TextView>(R.id.pee_summary)
pooSummary.text = String.format(Locale.getDefault(), "%d× | Ø %.1f",
todaySummary.diaperPooCount, diaperStats.avgPooPerDay)
peeSummary.text = String.format(Locale.getDefault(), "%d× | Ø %.1f",
todaySummary.diaperPeeCount, diaperStats.avgPeePerDay)
// Health card (weight/temperature)
val healthCard = view.findViewById<LinearLayout>(R.id.health_card)
val weightSummary = view.findViewById<TextView>(R.id.weight_summary)
val tempSummary = view.findViewById<TextView>(R.id.temperature_summary)
if (todaySummary.latestWeight != null || todaySummary.latestTemperature != null) {
healthCard.visibility = View.VISIBLE
if (todaySummary.latestWeight != null) {
val weightKg = todaySummary.latestWeight / 1000f
weightSummary.text = "⚖️ ${String.format(Locale.getDefault(), "%.2f kg", weightKg)}"
weightSummary.visibility = View.VISIBLE
} else {
weightSummary.visibility = View.GONE
}
if (todaySummary.latestTemperature != null) {
val tempC = todaySummary.latestTemperature / 10f
tempSummary.text = "🌡️ ${String.format(Locale.getDefault(), "%.1f °C", tempC)}"
tempSummary.visibility = View.VISIBLE
} else {
tempSummary.visibility = View.GONE
}
} else {
healthCard.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,124 @@
package it.danieleverducci.lunatracker.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.StatisticsActivity
import utils.DateUtils
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class DiaperStatsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_diaper_stats, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI(view)
}
private fun updateUI(view: View) {
val activity = activity as? StatisticsActivity ?: return
val calculator = activity.getCalculator()
val period = activity.getSelectedPeriod()
val stats = calculator.getDiaperStats(period)
// Draw stacked bar chart
val chartContainer = view.findViewById<LinearLayout>(R.id.chart_container)
val chartLabels = view.findViewById<LinearLayout>(R.id.chart_labels)
chartContainer.removeAllViews()
chartLabels.removeAllViews()
val sortedDays = stats.dailyPooCount.keys.sorted().takeLast(period)
var maxValue = 1
for (day in sortedDays) {
val total = (stats.dailyPooCount[day] ?: 0) + (stats.dailyPeeCount[day] ?: 0)
if (total > maxValue) maxValue = total
}
val dateFormat = SimpleDateFormat("E", Locale.getDefault())
for (day in sortedDays) {
val pooCount = stats.dailyPooCount[day] ?: 0
val peeCount = stats.dailyPeeCount[day] ?: 0
val total = pooCount + peeCount
// Bar container
val barContainer = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
setPadding(4, 0, 4, 0)
}
// Pee bar (lighter, on bottom)
if (peeCount > 0) {
val peeHeight = (peeCount.toFloat() / maxValue * 100).toInt()
val peeBar = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(peeHeight * resources.displayMetrics.density).toInt()
)
setBackgroundColor(0x66FFE68F.toInt()) // Semi-transparent accent
}
barContainer.addView(peeBar, 0)
}
// Poo bar (solid, on top)
if (pooCount > 0) {
val pooHeight = (pooCount.toFloat() / maxValue * 100).toInt()
val pooBar = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(pooHeight * resources.displayMetrics.density).toInt()
)
setBackgroundColor(resources.getColor(R.color.accent, null))
}
barContainer.addView(pooBar, 0)
}
chartContainer.addView(barContainer)
// Label
val label = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
text = dateFormat.format(Date(day * 1000))
textSize = 10f
gravity = Gravity.CENTER
}
chartLabels.addView(label)
}
// Summary stats
view.findViewById<TextView>(R.id.avg_diapers).text =
getString(R.string.stats_avg_diapers, stats.avgDiapersPerDay)
view.findViewById<TextView>(R.id.avg_poo).text =
getString(R.string.stats_avg_poo, stats.avgPooPerDay)
view.findViewById<TextView>(R.id.avg_pee).text =
getString(R.string.stats_avg_pee, stats.avgPeePerDay)
// Last poo
val lastPoo = view.findViewById<TextView>(R.id.last_poo)
if (stats.lastPooTime != null) {
val timeAgo = DateUtils.formatTimeAgo(requireContext(), stats.lastPooTime)
lastPoo.text = getString(R.string.stats_last_poo, timeAgo)
lastPoo.visibility = View.VISIBLE
} else {
lastPoo.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,123 @@
package it.danieleverducci.lunatracker.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.StatisticsActivity
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class FeedingStatsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_feeding_stats, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI(view)
}
private fun updateUI(view: View) {
val activity = activity as? StatisticsActivity ?: return
val calculator = activity.getCalculator()
val period = activity.getSelectedPeriod()
val stats = calculator.getFeedingStats(period)
// Draw bar chart
val chartContainer = view.findViewById<LinearLayout>(R.id.chart_container)
val chartLabels = view.findViewById<LinearLayout>(R.id.chart_labels)
chartContainer.removeAllViews()
chartLabels.removeAllViews()
val sortedDays = stats.dailyBottleTotals.keys.sorted().takeLast(period)
val maxValue = (stats.dailyBottleTotals.values.maxOrNull() ?: 1).coerceAtLeast(1)
val dateFormat = SimpleDateFormat("E", Locale.getDefault())
for (day in sortedDays) {
val value = stats.dailyBottleTotals[day] ?: 0
// Bar
val barContainer = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
setPadding(4, 0, 4, 0)
}
val barHeight = if (maxValue > 0) (value.toFloat() / maxValue * 100).toInt() else 0
val bar = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0
).apply {
height = (barHeight * resources.displayMetrics.density).toInt()
}
setBackgroundColor(resources.getColor(R.color.accent, null))
}
barContainer.addView(bar)
chartContainer.addView(barContainer)
// Label
val label = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
text = dateFormat.format(Date(day * 1000))
textSize = 10f
gravity = Gravity.CENTER
}
chartLabels.addView(label)
}
// Bottle stats
val bottleAvg = view.findViewById<TextView>(R.id.bottle_avg_daily)
bottleAvg.text = getString(R.string.stats_avg_ml_per_day, stats.avgBottleMlPerDay)
val feedingInterval = view.findViewById<TextView>(R.id.feeding_interval)
feedingInterval.text = getString(R.string.stats_feeding_interval, stats.avgFeedingIntervalMinutes.toInt())
// Breastfeeding stats
val breastfeedingCard = view.findViewById<LinearLayout>(R.id.breastfeeding_card)
val totalBreastfeeding = stats.leftBreastCount + stats.rightBreastCount + stats.bothBreastCount
if (totalBreastfeeding > 0) {
breastfeedingCard.visibility = View.VISIBLE
val avgDuration = view.findViewById<TextView>(R.id.breastfeeding_avg_duration)
avgDuration.text = getString(R.string.stats_avg_duration, stats.avgBreastfeedingDuration)
// Side distribution (excluding "both")
val sideTotal = stats.leftBreastCount + stats.rightBreastCount
if (sideTotal > 0) {
val leftPercent = (stats.leftBreastCount.toFloat() / sideTotal * 100).toInt()
val rightPercent = (stats.rightBreastCount.toFloat() / sideTotal * 100).toInt()
val leftProgress = view.findViewById<ProgressBar>(R.id.left_progress)
val rightProgress = view.findViewById<ProgressBar>(R.id.right_progress)
val leftPercentText = view.findViewById<TextView>(R.id.left_percent)
val rightPercentText = view.findViewById<TextView>(R.id.right_percent)
leftProgress.max = 100
leftProgress.progress = leftPercent
rightProgress.max = 100
rightProgress.progress = rightPercent
leftPercentText.text = "$leftPercent%"
rightPercentText.text = "$rightPercent%"
}
} else {
breastfeedingCard.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,139 @@
package it.danieleverducci.lunatracker.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.StatisticsActivity
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class GrowthStatsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_growth_stats, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI(view)
}
private fun updateUI(view: View) {
val activity = activity as? StatisticsActivity ?: return
val calculator = activity.getCalculator()
val weightHistory = calculator.getWeightHistory()
val noDataMessage = view.findViewById<TextView>(R.id.no_data_message)
if (weightHistory.isEmpty()) {
noDataMessage.visibility = View.VISIBLE
view.findViewById<View>(R.id.chart_container).visibility = View.GONE
view.findViewById<View>(R.id.chart_labels).visibility = View.GONE
view.findViewById<TextView>(R.id.current_weight).visibility = View.GONE
view.findViewById<TextView>(R.id.weight_gain_week).visibility = View.GONE
view.findViewById<TextView>(R.id.weight_gain_month).visibility = View.GONE
return
}
noDataMessage.visibility = View.GONE
// Draw weight chart (line chart approximated with bars)
val chartContainer = view.findViewById<LinearLayout>(R.id.chart_container)
val chartLabels = view.findViewById<LinearLayout>(R.id.chart_labels)
chartContainer.removeAllViews()
chartLabels.removeAllViews()
val recentWeights = weightHistory.takeLast(10) // Show last 10 measurements
val minWeight = recentWeights.minOfOrNull { it.weightGrams } ?: 0
val maxWeight = recentWeights.maxOfOrNull { it.weightGrams } ?: 1
val weightRange = (maxWeight - minWeight).coerceAtLeast(100) // At least 100g range
val dateFormat = SimpleDateFormat("d/M", Locale.getDefault())
for (point in recentWeights) {
// Bar container
val barContainer = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
setPadding(4, 0, 4, 0)
}
// Calculate relative height (showing weight above minimum)
val relativeWeight = point.weightGrams - minWeight + (weightRange * 0.1).toInt()
val barHeight = (relativeWeight.toFloat() / (weightRange * 1.2) * 100).toInt()
val bar = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(barHeight * resources.displayMetrics.density).toInt()
)
setBackgroundColor(resources.getColor(R.color.accent, null))
}
barContainer.addView(bar)
// Weight value on top
val weightLabel = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val kg = point.weightGrams / 1000f
text = String.format(Locale.getDefault(), "%.1f", kg)
textSize = 8f
gravity = Gravity.CENTER
}
barContainer.addView(weightLabel, 0)
chartContainer.addView(barContainer)
// Date label
val label = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
text = dateFormat.format(Date(point.time * 1000))
textSize = 10f
gravity = Gravity.CENTER
}
chartLabels.addView(label)
}
// Current weight
val currentWeight = weightHistory.lastOrNull()
if (currentWeight != null) {
val kg = currentWeight.weightGrams / 1000f
view.findViewById<TextView>(R.id.current_weight).text =
getString(R.string.stats_current_weight, String.format(Locale.getDefault(), "%.2f kg", kg))
}
// Weight gain calculations
val gainWeek = calculator.getWeightGainForDays(7)
val gainMonth = calculator.getWeightGainForDays(30)
val weekView = view.findViewById<TextView>(R.id.weight_gain_week)
val monthView = view.findViewById<TextView>(R.id.weight_gain_month)
if (gainWeek != null) {
weekView.text = getString(R.string.stats_weight_gain_week, gainWeek)
weekView.visibility = View.VISIBLE
} else {
weekView.visibility = View.GONE
}
if (gainMonth != null) {
monthView.text = getString(R.string.stats_weight_gain_month, gainMonth)
monthView.visibility = View.VISIBLE
} else {
monthView.visibility = View.GONE
}
}
}

View File

@@ -0,0 +1,105 @@
package it.danieleverducci.lunatracker.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import it.danieleverducci.lunatracker.R
import it.danieleverducci.lunatracker.StatisticsActivity
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class SleepStatsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_sleep_stats, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
updateUI(view)
}
private fun updateUI(view: View) {
val activity = activity as? StatisticsActivity ?: return
val calculator = activity.getCalculator()
val period = activity.getSelectedPeriod()
val stats = calculator.getSleepStats(period)
// Check if we have any sleep data
val hasSleepData = stats.dailyTotals.values.any { it > 0 }
val noDataMessage = view.findViewById<TextView>(R.id.no_data_message)
if (!hasSleepData) {
noDataMessage.visibility = View.VISIBLE
view.findViewById<View>(R.id.chart_container).visibility = View.GONE
view.findViewById<View>(R.id.chart_labels).visibility = View.GONE
return
}
noDataMessage.visibility = View.GONE
// Draw bar chart (showing hours per day)
val chartContainer = view.findViewById<LinearLayout>(R.id.chart_container)
val chartLabels = view.findViewById<LinearLayout>(R.id.chart_labels)
chartContainer.removeAllViews()
chartLabels.removeAllViews()
val sortedDays = stats.dailyTotals.keys.sorted().takeLast(period)
val maxValue = (stats.dailyTotals.values.maxOrNull() ?: 1).coerceAtLeast(1)
val dateFormat = SimpleDateFormat("E", Locale.getDefault())
for (day in sortedDays) {
val minutes = stats.dailyTotals[day] ?: 0
// Bar container
val barContainer = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
setPadding(4, 0, 4, 0)
}
val barHeight = if (maxValue > 0) (minutes.toFloat() / maxValue * 100).toInt() else 0
val bar = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(barHeight * resources.displayMetrics.density).toInt()
)
setBackgroundColor(resources.getColor(R.color.accent, null))
}
barContainer.addView(bar)
chartContainer.addView(barContainer)
// Label
val label = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
text = dateFormat.format(Date(day * 1000))
textSize = 10f
gravity = Gravity.CENTER
}
chartLabels.addView(label)
}
// Summary stats
val avgSleepHours = stats.avgSleepMinPerDay / 60f
view.findViewById<TextView>(R.id.avg_sleep_per_day).text =
getString(R.string.stats_avg_sleep, avgSleepHours)
view.findViewById<TextView>(R.id.avg_naps_per_day).text =
getString(R.string.stats_avg_naps, stats.avgNapsPerDay)
view.findViewById<TextView>(R.id.avg_nap_duration).text =
getString(R.string.stats_avg_nap_duration, stats.avgNapDurationMin)
view.findViewById<TextView>(R.id.longest_sleep).text =
getString(R.string.stats_longest_sleep, stats.longestSleepMin)
}
}

View File

@@ -17,6 +17,7 @@ class LocalSettingsRepository(val context: Context) {
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"
const val SHARED_PREFS_SLEEP_TIMER_START = "sleep_timer_start"
}
enum class DATA_REPO {LOCAL_FILE, WEBDAV}
val sharedPreferences: SharedPreferences
@@ -107,4 +108,20 @@ class LocalSettingsRepository(val context: Context) {
remove(SHARED_PREFS_BF_TIMER_TYPE)
}
}
fun saveSleepTimer(startTime: Long) {
sharedPreferences.edit {
putLong(SHARED_PREFS_SLEEP_TIMER_START, startTime)
}
}
fun loadSleepTimer(): Long {
return sharedPreferences.getLong(SHARED_PREFS_SLEEP_TIMER_START, 0)
}
fun clearSleepTimer() {
sharedPreferences.edit {
remove(SHARED_PREFS_SLEEP_TIMER_START)
}
}
}