mirror of
https://github.com/oliexdev/openScale.git
synced 2025-10-28 14:25:17 +01:00
Refactor: Introduce PeriodChart for enhanced navigation
This commit refactors the `LineChart` composable into a more generic `MeasurementChart` and introduces a new `PeriodChart`. The `PeriodChart` is a bar chart that groups measurements by selectable time periods (day, week, month, or year), allowing users to filter the main measurement graph by clicking on a specific period. This provides an intuitive way to navigate and analyze measurement data over time.
This commit is contained in:
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -105,10 +106,14 @@ import com.patrykandpatrick.vico.core.common.data.ExtraStore
|
||||
import com.patrykandpatrick.vico.core.common.shape.CorneredShape
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
@@ -135,11 +140,12 @@ private const val SHOW_TYPE_FILTER_ROW_SUFFIX = "_show_type_filter_row"
|
||||
* This is useful for focused views, like a detail screen for one measurement type.
|
||||
*/
|
||||
@Composable
|
||||
fun LineChart(
|
||||
fun MeasurementChart(
|
||||
modifier: Modifier = Modifier,
|
||||
sharedViewModel: SharedViewModel,
|
||||
screenContextName: String,
|
||||
showFilterControls: Boolean,
|
||||
showPeriodChart: Boolean = false,
|
||||
showFilterTitle: Boolean = false,
|
||||
showYAxis: Boolean = true,
|
||||
targetMeasurementTypeId: Int? = null,
|
||||
@@ -218,13 +224,133 @@ fun LineChart(
|
||||
}
|
||||
}
|
||||
|
||||
val fullyFilteredEnrichedMeasurements = remember(smoothedData, currentSelectedTypeIntIds) {
|
||||
sharedViewModel.filterEnrichedMeasurementsByTypes(smoothedData, currentSelectedTypeIntIds)
|
||||
var selectedPeriod by remember { mutableStateOf<PeriodDataPoint?>(null) }
|
||||
|
||||
val lineChartMeasurements = remember(smoothedData, selectedPeriod) {
|
||||
if (selectedPeriod == null) smoothedData
|
||||
else smoothedData.filter { measurement ->
|
||||
val ts = measurement.measurementWithValues.measurement.timestamp
|
||||
ts >= selectedPeriod!!.startTimestamp && ts < selectedPeriod!!.endTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
val measurementsForPeriodChart = remember(smoothedData) {
|
||||
smoothedData.map { it.measurementWithValues }
|
||||
}
|
||||
|
||||
val periodChartData = remember(measurementsForPeriodChart, uiSelectedTimeRange) {
|
||||
if (measurementsForPeriodChart.isEmpty()) return@remember emptyList<PeriodDataPoint>()
|
||||
|
||||
// Determine min and max date of filtered measurements
|
||||
val minDate = measurementsForPeriodChart.minOf {
|
||||
Instant.ofEpochMilli(it.measurement.timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}
|
||||
val maxDate = measurementsForPeriodChart.maxOf {
|
||||
Instant.ofEpochMilli(it.measurement.timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}
|
||||
|
||||
// Decide grouping dynamically
|
||||
val totalDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt()
|
||||
val groupingUnit: ChronoUnit
|
||||
val intervalSize: Long
|
||||
|
||||
when {
|
||||
totalDays <= 7 -> {
|
||||
groupingUnit = ChronoUnit.DAYS
|
||||
intervalSize = 1
|
||||
}
|
||||
totalDays <= 30 -> {
|
||||
groupingUnit = ChronoUnit.WEEKS
|
||||
intervalSize = 1
|
||||
}
|
||||
totalDays <= 365 -> {
|
||||
groupingUnit = ChronoUnit.MONTHS
|
||||
intervalSize = 1
|
||||
}
|
||||
else -> {
|
||||
groupingUnit = ChronoUnit.YEARS
|
||||
intervalSize = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Generate periods from minDate to maxDate
|
||||
val allPeriods = mutableListOf<LocalDate>()
|
||||
var cursor = when (groupingUnit) {
|
||||
ChronoUnit.DAYS -> minDate
|
||||
ChronoUnit.WEEKS -> minDate.with(DayOfWeek.MONDAY)
|
||||
ChronoUnit.MONTHS -> minDate.withDayOfMonth(1)
|
||||
else -> minDate.withDayOfYear(1)
|
||||
}
|
||||
|
||||
while (!cursor.isAfter(maxDate)) {
|
||||
allPeriods.add(cursor)
|
||||
cursor = when (groupingUnit) {
|
||||
ChronoUnit.DAYS -> cursor.plusDays(intervalSize)
|
||||
ChronoUnit.WEEKS -> cursor.plusWeeks(intervalSize)
|
||||
ChronoUnit.MONTHS -> cursor.plusMonths(intervalSize)
|
||||
else -> cursor.plusYears(intervalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure minimum 5 periods for better chart appearance
|
||||
while (allPeriods.size < 5) {
|
||||
cursor = when (groupingUnit) {
|
||||
ChronoUnit.DAYS -> allPeriods.first().minusDays(intervalSize)
|
||||
ChronoUnit.WEEKS -> allPeriods.first().minusWeeks(intervalSize)
|
||||
ChronoUnit.MONTHS -> allPeriods.first().minusMonths(intervalSize)
|
||||
else -> allPeriods.first().minusYears(intervalSize)
|
||||
}
|
||||
allPeriods.add(0, cursor)
|
||||
}
|
||||
|
||||
// Group measurements by period
|
||||
val grouped = measurementsForPeriodChart.groupBy { mwv ->
|
||||
val date = Instant.ofEpochMilli(mwv.measurement.timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
when (groupingUnit) {
|
||||
ChronoUnit.DAYS -> date
|
||||
ChronoUnit.WEEKS -> date.with(DayOfWeek.MONDAY)
|
||||
ChronoUnit.MONTHS -> date.withDayOfMonth(1)
|
||||
else -> date.withDayOfYear(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Localized label formatter
|
||||
val locale = Locale.getDefault()
|
||||
val labelFormatter: (LocalDate) -> String = { date ->
|
||||
when (groupingUnit) {
|
||||
ChronoUnit.DAYS -> date.format(DateTimeFormatter.ofPattern("d LLL", locale))
|
||||
ChronoUnit.WEEKS -> "W${date.get(WeekFields.of(locale).weekOfWeekBasedYear())}"
|
||||
ChronoUnit.MONTHS -> date.format(DateTimeFormatter.ofPattern("LLL yy", locale))
|
||||
else -> date.year.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// Map all periods to PeriodDataPoint, even empty ones
|
||||
allPeriods.mapIndexed { index, periodStart ->
|
||||
val periodEnd = if (index + 1 < allPeriods.size)
|
||||
allPeriods[index + 1].atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
else
|
||||
maxDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
|
||||
val measurementsInPeriod = grouped[periodStart] ?: emptyList()
|
||||
PeriodDataPoint(
|
||||
label = labelFormatter(periodStart),
|
||||
count = measurementsInPeriod.size,
|
||||
startTimestamp = periodStart.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||
endTimestamp = periodEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Extracting measurements with their values for plotting.
|
||||
val measurementsWithValues = remember(fullyFilteredEnrichedMeasurements) {
|
||||
fullyFilteredEnrichedMeasurements.map { it.measurementWithValues }
|
||||
val measurementsWithValues = remember(lineChartMeasurements) {
|
||||
lineChartMeasurements.map { it.measurementWithValues }
|
||||
}
|
||||
|
||||
// Determine which measurement types to actually plot based on current selections,
|
||||
@@ -315,6 +441,23 @@ fun LineChart(
|
||||
}
|
||||
}
|
||||
|
||||
if (showPeriodChart && periodChartData.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
PeriodChart(
|
||||
data = periodChartData,
|
||||
selectedPeriod = selectedPeriod,
|
||||
onPeriodClick = { clicked ->
|
||||
selectedPeriod = if (selectedPeriod == clicked) null else clicked
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var showNoDataMessage by remember { mutableStateOf(false) }
|
||||
var noDataMessageText by remember { mutableStateOf("") }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.ui.screen.components
|
||||
|
||||
import android.R.attr.textSize
|
||||
import android.text.Layout
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.stacked
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
|
||||
import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
|
||||
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
|
||||
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
|
||||
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
|
||||
import com.patrykandpatrick.vico.core.cartesian.layer.ColumnCartesianLayer
|
||||
import com.patrykandpatrick.vico.core.cartesian.marker.ColumnCartesianLayerMarkerTarget
|
||||
import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker
|
||||
import com.patrykandpatrick.vico.core.common.Fill
|
||||
import com.patrykandpatrick.vico.core.common.component.LineComponent
|
||||
import com.patrykandpatrick.vico.core.common.component.TextComponent
|
||||
import com.patrykandpatrick.vico.core.common.data.ExtraStore
|
||||
|
||||
private val BottomAxisLabelKey = ExtraStore.Key<List<String>>()
|
||||
|
||||
/**
|
||||
* Data class representing a single period column in the chart.
|
||||
*
|
||||
* @property label Display label for the period (e.g., "Jan 25").
|
||||
* @property count Value of the column.
|
||||
* @property startTimestamp Start timestamp for the period.
|
||||
* @property endTimestamp End timestamp for the period.
|
||||
*/
|
||||
data class PeriodDataPoint(
|
||||
val label: String,
|
||||
val count: Int,
|
||||
val startTimestamp: Long,
|
||||
val endTimestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Composable displaying a selectable stacked column chart of periods.
|
||||
*
|
||||
* Selection/deselection triggers only on pointer release to prevent repeated firing
|
||||
* when the mouse is held down.
|
||||
*
|
||||
* @param modifier Modifier for layout/styling
|
||||
* @param data List of [PeriodDataPoint] to display
|
||||
* @param selectedPeriod Currently selected period, or null
|
||||
* @param onPeriodClick Callback when a period is selected or deselected
|
||||
*/
|
||||
@Composable
|
||||
fun PeriodChart(
|
||||
modifier: Modifier = Modifier,
|
||||
data: List<PeriodDataPoint>,
|
||||
selectedPeriod: PeriodDataPoint?,
|
||||
onPeriodClick: (PeriodDataPoint?) -> Unit
|
||||
) {
|
||||
// Fill colors for unselected and selected bars
|
||||
val unselectedColor = Fill(MaterialTheme.colorScheme.primaryContainer.toArgb())
|
||||
val selectedColor = Fill(MaterialTheme.colorScheme.primary.toArgb())
|
||||
|
||||
// Chart model producer that holds and updates the dataset
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
|
||||
// Define a stacked column layer: one series for unselected, one for selected items
|
||||
val columnLayer = rememberColumnCartesianLayer(
|
||||
ColumnCartesianLayer.ColumnProvider.series(
|
||||
listOf(
|
||||
LineComponent(fill = unselectedColor, thicknessDp = 12f),
|
||||
LineComponent(fill = selectedColor, thicknessDp = 12f)
|
||||
)
|
||||
),
|
||||
mergeMode = { ColumnCartesianLayer.MergeMode.stacked() },
|
||||
)
|
||||
|
||||
// Update the chart model when data or selected period changes
|
||||
LaunchedEffect(data, selectedPeriod) {
|
||||
if (data.isNotEmpty()) {
|
||||
modelProducer.runTransaction {
|
||||
columnSeries {
|
||||
// First series: all unselected items
|
||||
series(data.map { if (it != selectedPeriod) it.count.toDouble() else 0.0 })
|
||||
// Second series: the selected item
|
||||
series(data.map { if (it == selectedPeriod) it.count.toDouble() else 0.0 })
|
||||
// Store labels for axis
|
||||
extras { it[BottomAxisLabelKey] = data.map { it.label } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track the currently hovered column index
|
||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// Reset hovered/selected state if data changes or selection is invalid
|
||||
LaunchedEffect(data) {
|
||||
hoveredIndex = null
|
||||
if (selectedPeriod != null && selectedPeriod !in data) {
|
||||
onPeriodClick(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Cartesian chart with a bottom axis and marker
|
||||
val chart = rememberCartesianChart(
|
||||
columnLayer,
|
||||
startAxis = null,
|
||||
bottomAxis = HorizontalAxis.rememberBottom(
|
||||
itemPlacer = HorizontalAxis.ItemPlacer.segmented(),
|
||||
valueFormatter = CartesianValueFormatter { context, x, _ ->
|
||||
val labels = context.model.extraStore[BottomAxisLabelKey]
|
||||
if (labels.isNotEmpty() && x.toInt() in labels.indices) labels[x.toInt()] else ""
|
||||
},
|
||||
guideline = null,
|
||||
label = rememberAxisLabelComponent(
|
||||
lineCount = 2, // allow wrapping if needed
|
||||
textSize = 9.sp,
|
||||
truncateAt = TextUtils.TruncateAt.MARQUEE
|
||||
)
|
||||
),
|
||||
marker = rememberMarker(
|
||||
DefaultCartesianMarker.ValueFormatter { _, targets ->
|
||||
val column = (targets.getOrNull(0) as? ColumnCartesianLayerMarkerTarget)
|
||||
?.columns?.firstOrNull()
|
||||
hoveredIndex = column?.entry?.x?.toInt()
|
||||
val hoveredData = hoveredIndex?.let { if (it in data.indices) data[it] else null }
|
||||
hoveredData?.let { "${it.label} (${it.count})" } ?: ""
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Chart host that handles pointer interactions (tap to select/deselect)
|
||||
CartesianChartHost(
|
||||
chart = chart,
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.pointerInput(data) {
|
||||
while (true) {
|
||||
awaitPointerEventScope {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.changes.all { it.changedToUp() }) {
|
||||
hoveredIndex?.let { index ->
|
||||
if (index in data.indices) {
|
||||
val clickedData = data[index]
|
||||
// Toggle selection
|
||||
if (clickedData == selectedPeriod) {
|
||||
onPeriodClick(null)
|
||||
} else {
|
||||
onPeriodClick(clickedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import com.health.openscale.core.model.EnrichedMeasurement
|
||||
import com.health.openscale.core.model.ValueWithDifference
|
||||
import com.health.openscale.ui.navigation.Routes
|
||||
import com.health.openscale.ui.shared.SharedViewModel
|
||||
import com.health.openscale.ui.screen.components.LineChart
|
||||
import com.health.openscale.ui.screen.components.MeasurementChart
|
||||
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||
import com.health.openscale.ui.screen.overview.MeasurementValueRow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -104,14 +104,15 @@ fun GraphScreen(
|
||||
Text(stringResource(R.string.no_data_available))
|
||||
}
|
||||
} else {
|
||||
LineChart(
|
||||
MeasurementChart(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
sharedViewModel = sharedViewModel,
|
||||
screenContextName = SettingsPreferenceKeys.GRAPH_SCREEN_CONTEXT,
|
||||
showFilterControls = true,
|
||||
showPeriodChart = true,
|
||||
onPointSelected = { selectedTs ->
|
||||
val result = sharedViewModel.findClosestMeasurement(selectedTs, allMeasurementsWithValues)
|
||||
?: return@LineChart
|
||||
?: return@MeasurementChart
|
||||
val (idx, mwv) = result
|
||||
val id = mwv.measurement.id
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@@ -126,7 +126,7 @@ import com.health.openscale.ui.components.RoundMeasurementIcon
|
||||
import com.health.openscale.ui.navigation.Routes
|
||||
import com.health.openscale.ui.shared.SharedViewModel
|
||||
import com.health.openscale.ui.screen.settings.BluetoothViewModel
|
||||
import com.health.openscale.ui.screen.components.LineChart
|
||||
import com.health.openscale.ui.screen.components.MeasurementChart
|
||||
import com.health.openscale.ui.screen.components.UserGoalChip
|
||||
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||
import com.health.openscale.ui.screen.dialog.UserGoalDialog
|
||||
@@ -578,7 +578,7 @@ fun OverviewScreen(
|
||||
|
||||
// Chart
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LineChart(
|
||||
MeasurementChart(
|
||||
sharedViewModel = sharedViewModel,
|
||||
screenContextName = SettingsPreferenceKeys.OVERVIEW_SCREEN_CONTEXT,
|
||||
showFilterControls = true,
|
||||
|
||||
@@ -61,11 +61,9 @@ import com.health.openscale.core.model.EnrichedMeasurement
|
||||
import com.health.openscale.core.utils.LocaleUtils
|
||||
import com.health.openscale.ui.components.RoundMeasurementIcon
|
||||
import com.health.openscale.ui.shared.SharedViewModel
|
||||
import com.health.openscale.ui.screen.components.LineChart
|
||||
import com.health.openscale.ui.screen.components.MeasurementChart
|
||||
import com.health.openscale.ui.screen.components.provideFilterTopBarAction
|
||||
import com.health.openscale.ui.screen.components.rememberContextualTimeRangeFilter
|
||||
import com.health.openscale.ui.shared.TopBarAction
|
||||
import java.text.DecimalFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
@@ -286,7 +284,7 @@ fun calculateStatisticsForType(
|
||||
* @param sharedViewModel The [SharedViewModel] instance.
|
||||
* @param measurementType The [MeasurementType] for which statistics are displayed.
|
||||
* @param statistics The calculated [MeasurementStatistics] for this type.
|
||||
* @param screenContextForChart A context name string used for the embedded [LineChart].
|
||||
* @param screenContextForChart A context name string used for the embedded [MeasurementChart].
|
||||
*/
|
||||
@Composable
|
||||
fun StatisticCard(
|
||||
@@ -352,7 +350,7 @@ fun StatisticCard(
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// --- MIDDLE: LineChart ---
|
||||
LineChart(
|
||||
MeasurementChart(
|
||||
sharedViewModel = sharedViewModel,
|
||||
screenContextName = screenContextForChart,
|
||||
showFilterControls = false, // Filter controls are global for the screen
|
||||
|
||||
Reference in New Issue
Block a user