From 8691a336125d82822df4ac6d5419671a56fb3202 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Wed, 13 Aug 2025 13:53:32 +0200 Subject: [PATCH] Add setting to show measurement type on right Y-axis --- .../7.json | 12 +- .../java/com/health/openscale/MainActivity.kt | 2 +- .../openscale/core/data/MeasurementType.kt | 3 +- .../ui/screen/components/LineChart.kt | 131 ++++++++++++++---- .../settings/MeasurementTypeDetailScreen.kt | 11 +- .../app/src/main/res/values-de/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 7 files changed, 129 insertions(+), 32 deletions(-) diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json index 7251c03d..581b85cb 100644 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/7.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "d539026586245a45a135ae5bf3aaf73c", + "identityHash": "877ad250be34067d136497a388177415", "entities": [ { "tableName": "User", @@ -214,7 +214,7 @@ }, { "tableName": "MeasurementType", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isOnRightYAxis` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -280,6 +280,12 @@ "columnName": "isPinned", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "isOnRightYAxis", + "columnName": "isOnRightYAxis", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -292,7 +298,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd539026586245a45a135ae5bf3aaf73c')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '877ad250be34067d136497a388177415')" ] } } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt index ee1773a5..e9323c87 100644 --- a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt +++ b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt @@ -59,7 +59,7 @@ import kotlinx.coroutines.launch */ fun getDefaultMeasurementTypes(): List { return listOf( - MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFFEF2929.toInt(), icon = "ic_weight", isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFFEF2929.toInt(), icon = "ic_weight", isPinned = true, isEnabled = true, isOnRightYAxis = true), MeasurementType(key = MeasurementTypeKey.BMI, color = 0xFFF57900.toInt(), icon = "ic_bmi", isDerived = true, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.BODY_FAT, color = 0xFFFFCE44.toInt(), icon = "ic_fat", isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WATER, color = 0xFF8AE234.toInt(), icon = "ic_water", isPinned = true, isEnabled = true), diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt index 32992e99..0a09349b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt @@ -34,7 +34,8 @@ data class MeasurementType( val displayOrder: Int = 0, val isDerived: Boolean = false, val isEnabled : Boolean = true, - val isPinned : Boolean = false + val isPinned : Boolean = false, + val isOnRightYAxis : Boolean = false ){ /** * Gets the appropriate display name for UI purposes. 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/LineChart.kt index d8974098..f6a849e4 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/LineChart.kt @@ -18,6 +18,7 @@ package com.health.openscale.ui.screen.components import android.text.Layout +import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -66,6 +67,7 @@ import com.health.openscale.ui.screen.SharedViewModel import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart import com.patrykandpatrick.vico.compose.cartesian.layer.point import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer @@ -80,9 +82,12 @@ import com.patrykandpatrick.vico.compose.common.fill import com.patrykandpatrick.vico.compose.common.insets import com.patrykandpatrick.vico.compose.common.shape.markerCorneredShape import com.patrykandpatrick.vico.core.cartesian.Zoom +import com.patrykandpatrick.vico.core.cartesian.axis.Axis import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis.ItemPlacer.Companion.count import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.core.cartesian.data.lineSeries import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer @@ -99,6 +104,8 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter +import kotlin.math.ceil +import kotlin.math.floor internal val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM") internal val X_TO_DATE_MAP_KEY = ExtraStore.Key>() // Key for storing date mapping in chart model @@ -390,34 +397,62 @@ fun LineChart( return@Column // Exits the Column Composable early } - // Determine colors for each series, using type's predefined color or gray as fallback. - val typeColors = remember(seriesEntries) { - seriesEntries.map { (type, _) -> // Index not used here + val seriesEntriesForStartAxis = remember(seriesEntries) { + seriesEntries.filter { (type, _) -> + !type.isOnRightYAxis + } + } + val typeColorsForStartAxis = remember(seriesEntriesForStartAxis) { + seriesEntriesForStartAxis.map { (type, _) -> + if (type.color != 0) Color(type.color) else Color.Gray + } + } + + val seriesEntriesForEndAxis = remember(seriesEntries) { + seriesEntries.filter { (type, _) -> + type.isOnRightYAxis + } + } + val typeColorsForEndAxis = remember(seriesEntriesForEndAxis) { + seriesEntriesForEndAxis.map { (type, _) -> if (type.color != 0) Color(type.color) else Color.Gray } } val modelProducer = remember { CartesianChartModelProducer() } - // Update the chart model when series data or the date map changes. - LaunchedEffect(seriesEntries, xToDatesMapForStore) { - if (seriesEntries.isNotEmpty()) { + LaunchedEffect(seriesEntriesForStartAxis, seriesEntriesForEndAxis, xToDatesMapForStore) { + if (seriesEntriesForStartAxis.isNotEmpty() || seriesEntriesForEndAxis.isNotEmpty()) { modelProducer.runTransaction { - lineSeries { // Vico's DSL for defining line series - seriesEntries.forEach { (_, sortedDateValuePairs) -> - val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } - val yValues = sortedDateValuePairs.map { it.second } - if (xValues.isNotEmpty()) { - series(x = xValues, y = yValues) + if (seriesEntriesForStartAxis.isNotEmpty()) { + lineSeries { + seriesEntriesForStartAxis.forEach { (_, sortedDateValuePairs) -> + val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } + val yValues = sortedDateValuePairs.map { it.second } + if (xValues.isNotEmpty()) { + series(x = xValues, y = yValues) + } } } } - extras { it[X_TO_DATE_MAP_KEY] = xToDatesMapForStore } // Store date map in model extras + + if (seriesEntriesForEndAxis.isNotEmpty()) { + lineSeries { + seriesEntriesForEndAxis.forEach { (_, sortedDateValuePairs) -> + val xValues = sortedDateValuePairs.map { it.first.toEpochDay().toFloat() } + val yValues = sortedDateValuePairs.map { it.second } + if (xValues.isNotEmpty()) { + series(x = xValues, y = yValues) + } + } + } + } + extras { it[X_TO_DATE_MAP_KEY] = xToDatesMapForStore } } } else { - // Clear the model if there are no series modelProducer.runTransaction { - lineSeries {} // Empty series + lineSeries {} + lineSeries {} extras { it.remove(X_TO_DATE_MAP_KEY) } } } @@ -442,31 +477,75 @@ fun LineChart( null // Hide X-axis when showing a single, targeted measurement type } + val rangeProvider = remember { + object : CartesianLayerRangeProvider { + override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + val r = maxY - minY + return if (r == 0.0) minY - 1.0 else floor(minY - 0.1 * r) + } + override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + val r = maxY - minY + return if (r == 0.0) maxY + 1.0 else ceil(maxY + 0.1 * r) + } + } + } + // Conditionally create Y-axis. - val yAxis = if (showYAxis) { - VerticalAxis.rememberStart( - valueFormatter = yAxisValueFormatter, - // guideline = rememberAxisGuidelineComponent(), // Optionally add Y-axis guidelines + val startYAxis = if (showYAxis) { + VerticalAxis.rememberStart(valueFormatter = yAxisValueFormatter) + } else { null } + + val endYAxis = if (showYAxis) { + VerticalAxis.rememberEnd(valueFormatter = yAxisValueFormatter) + } else { null } + + val lineProviderForStartAxis = remember(seriesEntriesForStartAxis, typeColorsForStartAxis) { + LineCartesianLayer.LineProvider.series( + seriesEntriesForStartAxis.mapIndexedNotNull { index, _ -> + if (index < typeColorsForStartAxis.size) { + createLineSpec(typeColorsForStartAxis[index], statisticsMode = targetMeasurementTypeId != null) + } else null + } + ) + } + val lineLayerForStartAxis = if (seriesEntriesForStartAxis.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = lineProviderForStartAxis, + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = rangeProvider ) } else { null } - // Define how lines are drawn (color, thickness, etc.) - val lineProvider = remember(seriesEntries, typeColors) { + val lineProviderForEndAxis = remember(seriesEntriesForEndAxis, typeColorsForEndAxis) { LineCartesianLayer.LineProvider.series( - seriesEntries.mapIndexedNotNull { index, _ -> - if (index < typeColors.size) createLineSpec(typeColors[index], statisticsMode = targetMeasurementTypeId != null) else null + seriesEntriesForEndAxis.mapIndexedNotNull { index, _ -> + if (index < typeColorsForEndAxis.size) { + createLineSpec(typeColorsForEndAxis[index], statisticsMode = targetMeasurementTypeId != null) + } else null } ) } + val lineLayerForEndAxis = if (seriesEntriesForEndAxis.isNotEmpty()) { + rememberLineCartesianLayer( + lineProvider = lineProviderForEndAxis, + verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = rangeProvider + ) + } else { + null + } - val lineLayer = rememberLineCartesianLayer(lineProvider = lineProvider) + val layers : List = remember(lineLayerForStartAxis, lineLayerForEndAxis) { + listOfNotNull(lineLayerForStartAxis, lineLayerForEndAxis) + } val chart = rememberCartesianChart( - lineLayer, - startAxis = yAxis, // Y-axis + layers = layers.toTypedArray(), + startAxis = startYAxis, // left Y-axis bottomAxis = xAxis, // X-axis + endAxis = endYAxis, // right Y-axis marker = rememberMarker() // Interactive marker for data points ) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt index ff2225fb..d8e42d72 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt @@ -110,6 +110,7 @@ fun MeasurementTypeDetailScreen( var selectedIcon by remember { mutableStateOf(existingType?.icon ?: "ic_weight") } // Default icon var isEnabled by remember { mutableStateOf(existingType?.isEnabled ?: true) } // Default to true for new types var isPinned by remember { mutableStateOf(existingType?.isPinned ?: false) } // Default to false for new types + var isOnRightYAxis by remember { mutableStateOf(existingType?.isOnRightYAxis ?: false) } var expandedUnit by remember { mutableStateOf(false) } var expandedInputType by remember { mutableStateOf(false) } @@ -138,7 +139,8 @@ fun MeasurementTypeDetailScreen( isEnabled = isEnabled, isPinned = isPinned, key = existingType?.key ?: MeasurementTypeKey.CUSTOM, // New types are custom - isDerived = existingType?.isDerived ?: false // New types are not derived by default + isDerived = existingType?.isDerived ?: false, // New types are not derived by default + isOnRightYAxis = isOnRightYAxis ) if (isEdit) { @@ -311,6 +313,13 @@ fun MeasurementTypeDetailScreen( onCheckedChange = { isPinned = it } ) } + + OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_on_right_y_axis)) { + Switch( + checked = isOnRightYAxis, + onCheckedChange = { isOnRightYAxis = it } + ) + } } // Color Picker Dialog diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 55c549e9..2b082243 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -141,6 +141,7 @@ Einheit Eingabetyp Angeheftet + Auf rechter Y-Achse Vorschau des ausgewählten Symbols Sortieren Bearbeiten diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 6d036b98..b09f8257 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ Unit Input Type Pinned + On right Y-axis Selected icon preview Sort Edit