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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user