From b6110c2cbb0b36a753b098b77bb01a2eb8fa74ca Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Thu, 8 Jan 2026 09:33:36 +0100 Subject: [PATCH] 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 --- app/src/main/AndroidManifest.xml | 4 + .../lunatracker/MainActivity.kt | 166 ++++++++- .../lunatracker/SettingsActivity.kt | 153 ++++++++ .../lunatracker/StatisticsActivity.kt | 207 +++++++++++ .../lunatracker/entities/LunaEvent.kt | 3 + .../fragments/DailySummaryFragment.kt | 123 +++++++ .../fragments/DiaperStatsFragment.kt | 124 +++++++ .../fragments/FeedingStatsFragment.kt | 123 +++++++ .../fragments/GrowthStatsFragment.kt | 139 +++++++ .../fragments/SleepStatsFragment.kt | 105 ++++++ .../repository/LocalSettingsRepository.kt | 17 + app/src/main/java/utils/NumericUtils.kt | 3 +- .../main/java/utils/StatisticsCalculator.kt | 343 ++++++++++++++++++ app/src/main/res/drawable/ic_back.xml | 12 + app/src/main/res/drawable/ic_sleep.xml | 12 + app/src/main/res/drawable/ic_statistics.xml | 16 + .../main/res/drawable/progress_bar_accent.xml | 17 + app/src/main/res/layout/activity_main.xml | 9 + app/src/main/res/layout/activity_settings.xml | 38 ++ .../main/res/layout/activity_statistics.xml | 57 +++ .../res/layout/fragment_daily_summary.xml | 274 ++++++++++++++ .../main/res/layout/fragment_diaper_stats.xml | 129 +++++++ .../res/layout/fragment_feeding_stats.xml | 175 +++++++++ .../main/res/layout/fragment_growth_stats.xml | 100 +++++ .../main/res/layout/fragment_sleep_stats.xml | 106 ++++++ app/src/main/res/layout/more_events_popup.xml | 10 + .../main/res/layout/sleep_duration_dialog.xml | 20 + .../main/res/layout/sleep_timer_dialog.xml | 36 ++ app/src/main/res/values-de/strings.xml | 82 +++++ app/src/main/res/values-fr/strings.xml | 82 +++++ app/src/main/res/values-it/strings.xml | 82 +++++ app/src/main/res/values/strings.xml | 83 +++++ 32 files changed, 2841 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/fragments/DailySummaryFragment.kt create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/fragments/DiaperStatsFragment.kt create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/fragments/FeedingStatsFragment.kt create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/fragments/GrowthStatsFragment.kt create mode 100644 app/src/main/java/it/danieleverducci/lunatracker/fragments/SleepStatsFragment.kt create mode 100644 app/src/main/java/utils/StatisticsCalculator.kt create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_sleep.xml create mode 100644 app/src/main/res/drawable/ic_statistics.xml create mode 100644 app/src/main/res/drawable/progress_bar_accent.xml create mode 100644 app/src/main/res/layout/activity_statistics.xml create mode 100644 app/src/main/res/layout/fragment_daily_summary.xml create mode 100644 app/src/main/res/layout/fragment_diaper_stats.xml create mode 100644 app/src/main/res/layout/fragment_feeding_stats.xml create mode 100644 app/src/main/res/layout/fragment_growth_stats.xml create mode 100644 app/src/main/res/layout/fragment_sleep_stats.xml create mode 100644 app/src/main/res/layout/sleep_duration_dialog.xml create mode 100644 app/src/main/res/layout/sleep_timer_dialog.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97f621b..b335ff9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ android:name=".SettingsActivity" android:label="@string/settings_title" android:theme="@style/Theme.LunaTracker"/> + \ No newline at end of file diff --git a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt index e80dddb..c3e0865 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt @@ -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(R.id.button_settings).setOnClickListener { showSettings() } + findViewById(R.id.button_statistics).setOnClickListener { + showStatistics() + } findViewById(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(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(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(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(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( + 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(R.id.button_sleep).setOnClickListener { + startSleepTimer() + dismiss() + } + contentView.findViewById(R.id.button_sleep).setOnLongClickListener { + askSleepDuration() + dismiss() + true + } contentView.findViewById(R.id.button_medicine).setOnClickListener { askNotes(LunaEvent(LunaEvent.TYPE_MEDICINE)) dismiss() diff --git a/app/src/main/java/it/danieleverducci/lunatracker/SettingsActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/SettingsActivity.kt index 8006986..80707e6 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/SettingsActivity.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/SettingsActivity.kt @@ -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(R.id.settings_cancel).setOnClickListener({ finish() }) + findViewById(R.id.settings_export).setOnClickListener({ + startExport() + }) + findViewById(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() + } + } \ No newline at end of file diff --git a/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt new file mode 100644 index 0000000..a15752d --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/StatisticsActivity.kt @@ -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 = 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(R.id.button_back).setOnClickListener { + finish() + } + + // Title with logbook name + logbookName = intent.getStringExtra(EXTRA_LOGBOOK_NAME) ?: "" + if (logbookName.isNotEmpty()) { + findViewById(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 = 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() + } + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt b/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt index e46ab25..1de111b 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt @@ -30,6 +30,7 @@ class LunaEvent: Comparable { 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 { 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 { 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 } ) diff --git a/app/src/main/java/it/danieleverducci/lunatracker/fragments/DailySummaryFragment.kt b/app/src/main/java/it/danieleverducci/lunatracker/fragments/DailySummaryFragment.kt new file mode 100644 index 0000000..422cd01 --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/fragments/DailySummaryFragment.kt @@ -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(R.id.date_header).text = dateFormat.format(Date()) + + // Bottle summary + val bottleSummary = view.findViewById(R.id.bottle_summary) + val bottleProgress = view.findViewById(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(R.id.breastfeeding_container) + val breastfeedingSummary = view.findViewById(R.id.breastfeeding_summary) + val breastfeedingProgress = view.findViewById(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(R.id.sleep_summary) + val sleepProgress = view.findViewById(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(R.id.poo_summary) + val peeSummary = view.findViewById(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(R.id.health_card) + val weightSummary = view.findViewById(R.id.weight_summary) + val tempSummary = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/fragments/DiaperStatsFragment.kt b/app/src/main/java/it/danieleverducci/lunatracker/fragments/DiaperStatsFragment.kt new file mode 100644 index 0000000..3c18cd4 --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/fragments/DiaperStatsFragment.kt @@ -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(R.id.chart_container) + val chartLabels = view.findViewById(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(R.id.avg_diapers).text = + getString(R.string.stats_avg_diapers, stats.avgDiapersPerDay) + view.findViewById(R.id.avg_poo).text = + getString(R.string.stats_avg_poo, stats.avgPooPerDay) + view.findViewById(R.id.avg_pee).text = + getString(R.string.stats_avg_pee, stats.avgPeePerDay) + + // Last poo + val lastPoo = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/fragments/FeedingStatsFragment.kt b/app/src/main/java/it/danieleverducci/lunatracker/fragments/FeedingStatsFragment.kt new file mode 100644 index 0000000..e53735e --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/fragments/FeedingStatsFragment.kt @@ -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(R.id.chart_container) + val chartLabels = view.findViewById(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(R.id.bottle_avg_daily) + bottleAvg.text = getString(R.string.stats_avg_ml_per_day, stats.avgBottleMlPerDay) + + val feedingInterval = view.findViewById(R.id.feeding_interval) + feedingInterval.text = getString(R.string.stats_feeding_interval, stats.avgFeedingIntervalMinutes.toInt()) + + // Breastfeeding stats + val breastfeedingCard = view.findViewById(R.id.breastfeeding_card) + val totalBreastfeeding = stats.leftBreastCount + stats.rightBreastCount + stats.bothBreastCount + + if (totalBreastfeeding > 0) { + breastfeedingCard.visibility = View.VISIBLE + + val avgDuration = view.findViewById(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(R.id.left_progress) + val rightProgress = view.findViewById(R.id.right_progress) + val leftPercentText = view.findViewById(R.id.left_percent) + val rightPercentText = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/fragments/GrowthStatsFragment.kt b/app/src/main/java/it/danieleverducci/lunatracker/fragments/GrowthStatsFragment.kt new file mode 100644 index 0000000..6589b01 --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/fragments/GrowthStatsFragment.kt @@ -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(R.id.no_data_message) + + if (weightHistory.isEmpty()) { + noDataMessage.visibility = View.VISIBLE + view.findViewById(R.id.chart_container).visibility = View.GONE + view.findViewById(R.id.chart_labels).visibility = View.GONE + view.findViewById(R.id.current_weight).visibility = View.GONE + view.findViewById(R.id.weight_gain_week).visibility = View.GONE + view.findViewById(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(R.id.chart_container) + val chartLabels = view.findViewById(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(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(R.id.weight_gain_week) + val monthView = view.findViewById(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 + } + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/fragments/SleepStatsFragment.kt b/app/src/main/java/it/danieleverducci/lunatracker/fragments/SleepStatsFragment.kt new file mode 100644 index 0000000..0de08da --- /dev/null +++ b/app/src/main/java/it/danieleverducci/lunatracker/fragments/SleepStatsFragment.kt @@ -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(R.id.no_data_message) + + if (!hasSleepData) { + noDataMessage.visibility = View.VISIBLE + view.findViewById(R.id.chart_container).visibility = View.GONE + view.findViewById(R.id.chart_labels).visibility = View.GONE + return + } + + noDataMessage.visibility = View.GONE + + // Draw bar chart (showing hours per day) + val chartContainer = view.findViewById(R.id.chart_container) + val chartLabels = view.findViewById(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(R.id.avg_sleep_per_day).text = + getString(R.string.stats_avg_sleep, avgSleepHours) + view.findViewById(R.id.avg_naps_per_day).text = + getString(R.string.stats_avg_naps, stats.avgNapsPerDay) + view.findViewById(R.id.avg_nap_duration).text = + getString(R.string.stats_avg_nap_duration, stats.avgNapDurationMin) + view.findViewById(R.id.longest_sleep).text = + getString(R.string.stats_longest_sleep, stats.longestSleepMin) + } +} diff --git a/app/src/main/java/it/danieleverducci/lunatracker/repository/LocalSettingsRepository.kt b/app/src/main/java/it/danieleverducci/lunatracker/repository/LocalSettingsRepository.kt index 196b64e..cfdb47e 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/repository/LocalSettingsRepository.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/repository/LocalSettingsRepository.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/utils/NumericUtils.kt b/app/src/main/java/utils/NumericUtils.kt index 6e7e5cb..9749d45 100644 --- a/app/src/main/java/utils/NumericUtils.kt +++ b/app/src/main/java/utils/NumericUtils.kt @@ -81,7 +81,8 @@ class NumericUtils (val context: Context) { LunaEvent.TYPE_TEMPERATURE -> measurement_unit_temperature_base LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE, LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE, - LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE -> + LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE, + LunaEvent.TYPE_SLEEP -> context.getString(R.string.measurement_unit_time_minutes) else -> "" } diff --git a/app/src/main/java/utils/StatisticsCalculator.kt b/app/src/main/java/utils/StatisticsCalculator.kt new file mode 100644 index 0000000..aed0964 --- /dev/null +++ b/app/src/main/java/utils/StatisticsCalculator.kt @@ -0,0 +1,343 @@ +package utils + +import it.danieleverducci.lunatracker.entities.LunaEvent +import java.util.Calendar + +/** + * Data classes for statistics results + */ +data class DailySummary( + val date: Long, + val totalBottleMl: Int, + val bottleCount: Int, + val totalBreastfeedingMin: Int, + val breastfeedingCount: Int, + val breastfeedingLeftCount: Int, + val breastfeedingRightCount: Int, + val totalSleepMin: Int, + val sleepCount: Int, + val diaperPooCount: Int, + val diaperPeeCount: Int, + val totalFoodCount: Int, + val latestWeight: Int?, + val latestTemperature: Int? +) + +data class FeedingStats( + val dailyBottleTotals: Map, + val dailyBreastfeedingTotals: Map, + val avgBottleMlPerDay: Float, + val avgBreastfeedingMinPerDay: Float, + val leftBreastCount: Int, + val rightBreastCount: Int, + val bothBreastCount: Int, + val avgBreastfeedingDuration: Float, + val avgFeedingIntervalMinutes: Long +) + +data class DiaperStats( + val dailyPooCount: Map, + val dailyPeeCount: Map, + val avgDiapersPerDay: Float, + val avgPooPerDay: Float, + val avgPeePerDay: Float, + val lastPooTime: Long? +) + +data class SleepStats( + val dailyTotals: Map, + val avgSleepMinPerDay: Float, + val avgNapsPerDay: Float, + val avgNapDurationMin: Float, + val longestSleepMin: Int, + val lastSleepTime: Long? +) + +data class WeightPoint( + val time: Long, + val weightGrams: Int +) + +data class TemperaturePoint( + val time: Long, + val temperatureDeciCelsius: Int +) + +/** + * Calculator for statistics based on LunaEvent data + */ +class StatisticsCalculator(private val events: List) { + + private fun getStartOfDay(unixTimeSeconds: Long): Long { + val cal = Calendar.getInstance() + cal.timeInMillis = unixTimeSeconds * 1000 + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + return cal.timeInMillis / 1000 + } + + private fun getEventsInRange(startUnix: Long, endUnix: Long): List { + return events.filter { it.time >= startUnix && it.time < endUnix } + } + + private fun getEventsForDays(days: Int): List { + val now = System.currentTimeMillis() / 1000 + val startOfToday = getStartOfDay(now) + val startTime = startOfToday - (days - 1) * 24 * 60 * 60 + return events.filter { it.time >= startTime } + } + + /** + * Get summary for a specific day (unix timestamp in seconds) + */ + fun getDailySummary(dayUnix: Long): DailySummary { + val startOfDay = getStartOfDay(dayUnix) + val endOfDay = startOfDay + 24 * 60 * 60 + val dayEvents = getEventsInRange(startOfDay, endOfDay) + + val bottleEvents = dayEvents.filter { it.type == LunaEvent.TYPE_BABY_BOTTLE } + val breastfeedingEvents = dayEvents.filter { + it.type == LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE || + it.type == LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE || + it.type == LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE + } + val sleepEvents = dayEvents.filter { it.type == LunaEvent.TYPE_SLEEP } + val pooEvents = dayEvents.filter { it.type == LunaEvent.TYPE_DIAPERCHANGE_POO } + val peeEvents = dayEvents.filter { it.type == LunaEvent.TYPE_DIAPERCHANGE_PEE } + val foodEvents = dayEvents.filter { it.type == LunaEvent.TYPE_FOOD } + val weightEvents = dayEvents.filter { it.type == LunaEvent.TYPE_WEIGHT } + val tempEvents = dayEvents.filter { it.type == LunaEvent.TYPE_TEMPERATURE } + + return DailySummary( + date = startOfDay, + totalBottleMl = bottleEvents.sumOf { it.quantity }, + bottleCount = bottleEvents.size, + totalBreastfeedingMin = breastfeedingEvents.sumOf { it.quantity }, + breastfeedingCount = breastfeedingEvents.size, + breastfeedingLeftCount = breastfeedingEvents.count { it.type == LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE }, + breastfeedingRightCount = breastfeedingEvents.count { it.type == LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE }, + totalSleepMin = sleepEvents.sumOf { it.quantity }, + sleepCount = sleepEvents.size, + diaperPooCount = pooEvents.size, + diaperPeeCount = peeEvents.size, + totalFoodCount = foodEvents.size, + latestWeight = weightEvents.maxByOrNull { it.time }?.quantity, + latestTemperature = tempEvents.maxByOrNull { it.time }?.quantity + ) + } + + /** + * Get today's summary + */ + fun getTodaySummary(): DailySummary { + return getDailySummary(System.currentTimeMillis() / 1000) + } + + /** + * Get feeding statistics for the last N days + */ + fun getFeedingStats(days: Int): FeedingStats { + val relevantEvents = getEventsForDays(days) + val now = System.currentTimeMillis() / 1000 + val startOfToday = getStartOfDay(now) + + // Daily totals + val dailyBottleTotals = mutableMapOf() + val dailyBreastfeedingTotals = mutableMapOf() + + for (i in 0 until days) { + val dayStart = startOfToday - i * 24 * 60 * 60 + dailyBottleTotals[dayStart] = 0 + dailyBreastfeedingTotals[dayStart] = 0 + } + + val bottleEvents = relevantEvents.filter { it.type == LunaEvent.TYPE_BABY_BOTTLE } + val breastfeedingEvents = relevantEvents.filter { + it.type == LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE || + it.type == LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE || + it.type == LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE + } + + bottleEvents.forEach { event -> + val dayStart = getStartOfDay(event.time) + dailyBottleTotals[dayStart] = (dailyBottleTotals[dayStart] ?: 0) + event.quantity + } + + breastfeedingEvents.forEach { event -> + val dayStart = getStartOfDay(event.time) + dailyBreastfeedingTotals[dayStart] = (dailyBreastfeedingTotals[dayStart] ?: 0) + event.quantity + } + + // Breastfeeding side distribution + val leftCount = breastfeedingEvents.count { it.type == LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE } + val rightCount = breastfeedingEvents.count { it.type == LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE } + val bothCount = breastfeedingEvents.count { it.type == LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE } + + // Average breastfeeding duration + val avgBreastfeedingDuration = if (breastfeedingEvents.isNotEmpty()) { + breastfeedingEvents.sumOf { it.quantity }.toFloat() / breastfeedingEvents.size + } else 0f + + // Average feeding interval (all feeding events sorted by time) + val allFeedingEvents = (bottleEvents + breastfeedingEvents).sortedBy { it.time } + val avgFeedingIntervalMinutes = if (allFeedingEvents.size > 1) { + var totalInterval = 0L + for (i in 1 until allFeedingEvents.size) { + totalInterval += allFeedingEvents[i].time - allFeedingEvents[i-1].time + } + (totalInterval / (allFeedingEvents.size - 1)) / 60 + } else 0L + + return FeedingStats( + dailyBottleTotals = dailyBottleTotals, + dailyBreastfeedingTotals = dailyBreastfeedingTotals, + avgBottleMlPerDay = if (days > 0) dailyBottleTotals.values.sum().toFloat() / days else 0f, + avgBreastfeedingMinPerDay = if (days > 0) dailyBreastfeedingTotals.values.sum().toFloat() / days else 0f, + leftBreastCount = leftCount, + rightBreastCount = rightCount, + bothBreastCount = bothCount, + avgBreastfeedingDuration = avgBreastfeedingDuration, + avgFeedingIntervalMinutes = avgFeedingIntervalMinutes + ) + } + + /** + * Get diaper statistics for the last N days + */ + fun getDiaperStats(days: Int): DiaperStats { + val relevantEvents = getEventsForDays(days) + val now = System.currentTimeMillis() / 1000 + val startOfToday = getStartOfDay(now) + + val dailyPooCount = mutableMapOf() + val dailyPeeCount = mutableMapOf() + + for (i in 0 until days) { + val dayStart = startOfToday - i * 24 * 60 * 60 + dailyPooCount[dayStart] = 0 + dailyPeeCount[dayStart] = 0 + } + + val pooEvents = relevantEvents.filter { it.type == LunaEvent.TYPE_DIAPERCHANGE_POO } + val peeEvents = relevantEvents.filter { it.type == LunaEvent.TYPE_DIAPERCHANGE_PEE } + + pooEvents.forEach { event -> + val dayStart = getStartOfDay(event.time) + dailyPooCount[dayStart] = (dailyPooCount[dayStart] ?: 0) + 1 + } + + peeEvents.forEach { event -> + val dayStart = getStartOfDay(event.time) + dailyPeeCount[dayStart] = (dailyPeeCount[dayStart] ?: 0) + 1 + } + + val totalDiapers = pooEvents.size + peeEvents.size + + return DiaperStats( + dailyPooCount = dailyPooCount, + dailyPeeCount = dailyPeeCount, + avgDiapersPerDay = if (days > 0) totalDiapers.toFloat() / days else 0f, + avgPooPerDay = if (days > 0) pooEvents.size.toFloat() / days else 0f, + avgPeePerDay = if (days > 0) peeEvents.size.toFloat() / days else 0f, + lastPooTime = pooEvents.maxByOrNull { it.time }?.time + ) + } + + /** + * Get sleep statistics for the last N days + */ + fun getSleepStats(days: Int): SleepStats { + val relevantEvents = getEventsForDays(days) + val now = System.currentTimeMillis() / 1000 + val startOfToday = getStartOfDay(now) + + val dailyTotals = mutableMapOf() + + for (i in 0 until days) { + val dayStart = startOfToday - i * 24 * 60 * 60 + dailyTotals[dayStart] = 0 + } + + val sleepEvents = relevantEvents.filter { it.type == LunaEvent.TYPE_SLEEP } + + sleepEvents.forEach { event -> + val dayStart = getStartOfDay(event.time) + dailyTotals[dayStart] = (dailyTotals[dayStart] ?: 0) + event.quantity + } + + val totalSleepMin = sleepEvents.sumOf { it.quantity } + val avgNapDuration = if (sleepEvents.isNotEmpty()) { + totalSleepMin.toFloat() / sleepEvents.size + } else 0f + + val longestSleep = sleepEvents.maxOfOrNull { it.quantity } ?: 0 + + return SleepStats( + dailyTotals = dailyTotals, + avgSleepMinPerDay = if (days > 0) totalSleepMin.toFloat() / days else 0f, + avgNapsPerDay = if (days > 0) sleepEvents.size.toFloat() / days else 0f, + avgNapDurationMin = avgNapDuration, + longestSleepMin = longestSleep, + lastSleepTime = sleepEvents.maxByOrNull { it.time }?.time + ) + } + + /** + * Get weight history (all weight measurements) + */ + fun getWeightHistory(): List { + return events + .filter { it.type == LunaEvent.TYPE_WEIGHT && it.quantity > 0 } + .sortedBy { it.time } + .map { WeightPoint(it.time, it.quantity) } + } + + /** + * Get temperature history + */ + fun getTemperatureHistory(): List { + return events + .filter { it.type == LunaEvent.TYPE_TEMPERATURE && it.quantity > 0 } + .sortedBy { it.time } + .map { TemperaturePoint(it.time, it.quantity) } + } + + /** + * Calculate weight gain over the last N days + */ + fun getWeightGainForDays(days: Int): Int? { + val weights = getWeightHistory() + if (weights.size < 2) return null + + val now = System.currentTimeMillis() / 1000 + val startTime = now - days * 24 * 60 * 60 + + val recentWeight = weights.lastOrNull() ?: return null + val olderWeight = weights.filter { it.time <= startTime }.lastOrNull() + ?: weights.firstOrNull() + ?: return null + + if (recentWeight.time == olderWeight.time) return null + + return recentWeight.weightGrams - olderWeight.weightGrams + } + + /** + * Get average daily values for a type of event over N days + */ + fun getAverageDailyCount(type: String, days: Int): Float { + val relevantEvents = getEventsForDays(days).filter { it.type == type } + return if (days > 0) relevantEvents.size.toFloat() / days else 0f + } + + /** + * Get average daily quantity sum for a type of event over N days + */ + fun getAverageDailyQuantity(type: String, days: Int): Float { + val relevantEvents = getEventsForDays(days).filter { it.type == type } + val total = relevantEvents.sumOf { it.quantity } + return if (days > 0) total.toFloat() / days else 0f + } +} diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..07ab466 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_sleep.xml b/app/src/main/res/drawable/ic_sleep.xml new file mode 100644 index 0000000..81ba46a --- /dev/null +++ b/app/src/main/res/drawable/ic_sleep.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_statistics.xml b/app/src/main/res/drawable/ic_statistics.xml new file mode 100644 index 0000000..c73f4bf --- /dev/null +++ b/app/src/main/res/drawable/ic_statistics.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/progress_bar_accent.xml b/app/src/main/res/drawable/progress_bar_accent.xml new file mode 100644 index 0000000..056e971 --- /dev/null +++ b/app/src/main/res/drawable/progress_bar_accent.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a04a510..ff01708 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -16,6 +16,15 @@ android:layout_height="wrap_content" android:orientation="horizontal"> + + + + + +