1
0
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:
oliexdev
2025-10-04 09:43:42 +02:00
parent 7510ee7361
commit e0667fc58c
5 changed files with 349 additions and 15 deletions

View File

@@ -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("") }

View File

@@ -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)
}
}
}
}
}
}
}
)
}

View File

@@ -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()

View File

@@ -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,

View File

@@ -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