From e0667fc58ccee68b4fa99cb540c8f895012da074 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 4 Oct 2025 09:43:42 +0200 Subject: [PATCH] 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. --- .../{LineChart.kt => MeasurementChart.kt} | 153 +++++++++++++- .../ui/screen/components/PeriodChart.kt | 192 ++++++++++++++++++ .../openscale/ui/screen/graph/GraphScreen.kt | 7 +- .../ui/screen/overview/OverviewScreen.kt | 4 +- .../ui/screen/statistics/StatisticsScreen.kt | 8 +- 5 files changed, 349 insertions(+), 15 deletions(-) rename android_app/app/src/main/java/com/health/openscale/ui/screen/components/{LineChart.kt => MeasurementChart.kt} (88%) create mode 100644 android_app/app/src/main/java/com/health/openscale/ui/screen/components/PeriodChart.kt diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt similarity index 88% rename from android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt rename to android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt index 8658df8a..84fc6ea3 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/LineChart.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt @@ -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(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() + + // 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() + 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("") } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/PeriodChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/PeriodChart.kt new file mode 100644 index 00000000..aaede706 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/PeriodChart.kt @@ -0,0 +1,192 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * 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 . + */ +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>() + +/** + * 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, + 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(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) + } + } + } + } + } + } + } + ) +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt index a920ec18..8c69b5f5 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt @@ -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() diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index 58f6eca6..ddd1295a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -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, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt index 163b877e..71d0b3b9 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/statistics/StatisticsScreen.kt @@ -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