From 98cc2ba9f57194a549904e1d0bedb7938901c4e9 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 16 Aug 2025 17:54:53 +0200 Subject: [PATCH] Add settings for chart data smoothing --- .../com/health/openscale/core/data/Enums.kt | 10 + .../core/database/UserSettingsRepository.kt | 72 ++++++- .../com/health/openscale/core/utils/Utils.kt | 78 ++++++++ .../openscale/ui/screen/SharedViewModel.kt | 145 ++++++++++++++ .../ui/screen/components/LineChart.kt | 24 ++- .../ui/screen/settings/ChartSettingsScreen.kt | 185 +++++++++++++++++- .../app/src/main/res/values-de/strings.xml | 6 + .../app/src/main/res/values/strings.xml | 6 + 8 files changed, 511 insertions(+), 15 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index ee079d5b..a746fcea 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -336,6 +336,16 @@ enum class TimeRangeFilter(@StringRes val displayNameResId: Int) { LAST_30_DAYS(R.string.time_range_last_30_days), LAST_365_DAYS(R.string.time_range_last_365_days); + fun getDisplayName(context: android.content.Context): String { + return context.getString(displayNameResId) + } +} + +enum class SmoothingAlgorithm(@StringRes val displayNameResId: Int) { + NONE(R.string.smoothing_algorithm_none), + SIMPLE_MOVING_AVERAGE(R.string.smoothing_algorithm_sma), + EXPONENTIAL_SMOOTHING(R.string.smoothing_algorithm_ses); + fun getDisplayName(context: android.content.Context): String { return context.getString(displayNameResId) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt index dccc3aad..ee7f01ee 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/UserSettingsRepository.kt @@ -30,6 +30,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.utils.LogManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -58,7 +59,10 @@ object UserPreferenceKeys { val SAVED_BLUETOOTH_SCALE_NAME = stringPreferencesKey("saved_bluetooth_scale_name") // Settings for chart - val SHOW_CHART_DATA_POINTS = booleanPreferencesKey("show_chart_data_points") + val CHART_SHOW_DATA_POINTS = booleanPreferencesKey("chart_show_data_points") + val CHART_SMOOTHING_ALGORITHM = stringPreferencesKey("chart_smoothing_algorithm") + val CHART_SMOOTHING_ALPHA = floatPreferencesKey("chart_smoothing_alpha") + val CHART_SMOOTHING_WINDOW_SIZE = intPreferencesKey("chart_smoothing_window_size") // Context strings for screen-specific settings (can be used as prefixes for dynamic keys) const val OVERVIEW_SCREEN_CONTEXT = "overview_screen" @@ -96,6 +100,15 @@ interface UserSettingsRepository { val showChartDataPoints: Flow suspend fun setShowChartDataPoints(show: Boolean) + val chartSmoothingAlgorithm: Flow + suspend fun setChartSmoothingAlgorithm(algorithm: SmoothingAlgorithm) + + val chartSmoothingAlpha: Flow + suspend fun setChartSmoothingAlpha(alpha: Float) + + val chartSmoothingWindowSize: Flow + suspend fun setChartSmoothingWindowSize(windowSize: Int) + // Generic Settings Accessors /** * Observes a setting with the given key name and default value. @@ -255,7 +268,7 @@ class UserSettingsRepositoryImpl(context: Context) : UserSettingsRepository { } override val showChartDataPoints: Flow = observeSetting( - UserPreferenceKeys.SHOW_CHART_DATA_POINTS.name, + UserPreferenceKeys.CHART_SHOW_DATA_POINTS.name, true ).catch { exception -> LogManager.e(TAG, "Error observing showChartDataPoints", exception) @@ -264,7 +277,60 @@ class UserSettingsRepositoryImpl(context: Context) : UserSettingsRepository { override suspend fun setShowChartDataPoints(show: Boolean) { LogManager.d(TAG, "Setting showChartDataPoints to: $show") - saveSetting(UserPreferenceKeys.SHOW_CHART_DATA_POINTS.name, show) + saveSetting(UserPreferenceKeys.CHART_SHOW_DATA_POINTS.name, show) + } + + override val chartSmoothingAlgorithm: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading chartSmoothingAlgorithm from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + val algorithmName = preferences[UserPreferenceKeys.CHART_SMOOTHING_ALGORITHM] + try { + algorithmName?.let { SmoothingAlgorithm.valueOf(it) } ?: SmoothingAlgorithm.NONE + } catch (e: IllegalArgumentException) { + LogManager.w(TAG, "Invalid smoothing algorithm name '$algorithmName' in DataStore. Defaulting to NONE.", e) + SmoothingAlgorithm.NONE + } + } + .distinctUntilChanged() + + override suspend fun setChartSmoothingAlgorithm(algorithm: SmoothingAlgorithm) { + LogManager.d(TAG, "Setting chart smoothing algorithm to: ${algorithm.name}") + saveSetting(UserPreferenceKeys.CHART_SMOOTHING_ALGORITHM.name, algorithm.name) + } + + override val chartSmoothingAlpha: Flow = observeSetting( + UserPreferenceKeys.CHART_SMOOTHING_ALPHA.name, + 0.5f + ).catch { exception -> + LogManager.e(TAG, "Error observing chartSmoothingAlpha", exception) + emit(0.5f) + } + + override suspend fun setChartSmoothingAlpha(alpha: Float) { + val validAlpha = alpha.coerceIn(0.01f, 0.99f) + LogManager.d(TAG, "Setting chart smoothing alpha to: $validAlpha (raw input: $alpha)") + saveSetting(UserPreferenceKeys.CHART_SMOOTHING_ALPHA.name, validAlpha) + } + + override val chartSmoothingWindowSize: Flow = observeSetting( + UserPreferenceKeys.CHART_SMOOTHING_WINDOW_SIZE.name, + 5 + ).catch { exception -> + LogManager.e(TAG, "Error observing chartSmoothingWindowSize", exception) + emit(5) + } + + override suspend fun setChartSmoothingWindowSize(windowSize: Int) { + val validWindowSize = windowSize.coerceIn(2, 50) + LogManager.d(TAG, "Setting chart smoothing window size to: $validWindowSize (raw input: $windowSize)") + saveSetting(UserPreferenceKeys.CHART_SMOOTHING_WINDOW_SIZE.name, validWindowSize) } @Suppress("UNCHECKED_CAST") diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt index 3ef07167..ec82f0f2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/Utils.kt @@ -46,6 +46,84 @@ object CalculationUtil { fun roundTo(value: Float): Float { return (value * 100).toInt() / 100.0f } + + // In SharedViewModel or a utility class + + /** + * Applies Single Exponential Smoothing to a list of data points. + * + * @param data The list of Float values to smooth. + * @param alpha The smoothing factor (0 < alpha <= 1). + * A higher alpha gives more weight to recent observations. + * @return A new list containing the smoothed data. The list will have the same size as the input. + */ + fun applyExponentialSmoothing(data: List, alpha: Float): List { + if (data.isEmpty()) { + return emptyList() + } + // Ensure alpha is within the valid range. + val validAlpha = alpha.coerceIn(0.01f, 1.0f) // Prevent alpha=0 which would freeze the series + + val smoothedValues = mutableListOf() + if (data.isNotEmpty()) { + // Initialize the first smoothed value. + // Common practice is to start with the first data point, + // though other initializations (like an average of the first few points) exist. + smoothedValues.add(data[0]) + + for (i in 1 until data.size) { + val smoothed = validAlpha * data[i] + (1 - validAlpha) * smoothedValues[i - 1] + smoothedValues.add(smoothed) + } + } + return smoothedValues + } + + /** + * Applies a Simple Moving Average (SMA) to a list of data points. + * + * @param data The list of Float values to average. + * @param windowSize The number of data points to include in each average. Must be positive. + * @return A new list containing the SMA values. + * The returned list will be shorter than the input list by (windowSize - 1) elements, + * as the SMA can only be calculated once enough data points are available to fill the window. + * Returns an empty list if data is empty or windowSize is invalid. + */ + fun applySimpleMovingAverage(data: List, windowSize: Int): List { + if (data.isEmpty() || windowSize <= 0) { + return emptyList() + } + // If window size is 1, it's just the original data (no averaging). + // Or if window size is larger than data size, we can't form a single complete window. + if (windowSize == 1) { + return data.toList() // Return a copy + } + if (windowSize > data.size) { + // Cannot compute any SMA if window is larger than available data. + // Alternatively, one might return the average of all available points, + // but standard SMA implies a full window. + return emptyList() + } + + val movingAverages = mutableListOf() + var currentSum = 0.0f + + // Calculate sum for the first window + for (i in 0 until windowSize) { + currentSum += data[i] + } + movingAverages.add(currentSum / windowSize) + + // Slide the window across the rest of the data + for (i in windowSize until data.size) { + currentSum -= data[i - windowSize] // Subtract the element that's leaving the window + currentSum += data[i] // Add the new element entering the window + movingAverages.add(currentSum / windowSize) + } + + return movingAverages + } + } /** diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt index 294b2c83..68fdaa95 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt @@ -20,10 +20,16 @@ package com.health.openscale.ui.screen import android.app.Application import android.content.ComponentName import android.content.Intent +import android.util.Log import androidx.annotation.StringRes +import androidx.compose.animation.core.copy +import androidx.compose.foundation.gestures.forEach +import androidx.compose.foundation.layout.size import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.isEmpty import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.type import androidx.core.content.ContextCompat import androidx.core.graphics.values import androidx.lifecycle.ViewModel @@ -37,6 +43,7 @@ import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.data.TimeRangeFilter import com.health.openscale.core.data.Trend import com.health.openscale.core.data.User @@ -45,6 +52,7 @@ import com.health.openscale.core.model.MeasurementValueWithType import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.utils.LogManager import com.health.openscale.core.database.UserSettingsRepository +import com.health.openscale.core.utils.CalculationUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -66,6 +74,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.Calendar import java.util.Date +import kotlin.math.roundToInt private const val TAG = "SharedViewModel" @@ -473,6 +482,142 @@ class SharedViewModel( LogManager.v(TAG, "enrichedMeasurementsFlow initialized. (Data Enrichment Flow)") } + + // --- Smoothing Settings (from UserSettingsRepository) --- + + val selectedSmoothingAlgorithm: StateFlow = + userSettingRepository.chartSmoothingAlgorithm + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = SmoothingAlgorithm.NONE + ).also { + LogManager.v(TAG, "selectedSmoothingAlgorithm flow initialized from repository.") + } + + val smoothingAlpha: StateFlow = + userSettingRepository.chartSmoothingAlpha + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = 0.5f + ).also { + LogManager.v(TAG, "smoothingAlpha flow initialized from repository.") + } + + val smoothingWindowSize: StateFlow = + userSettingRepository.chartSmoothingWindowSize + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = 5 // Default from UserSettingsRepositoryImpl (was 5, I had 3 before, use your actual default) + ).also { + LogManager.v(TAG, "smoothingWindowSize flow initialized from repository.") + } + + + fun getSmoothedEnrichedMeasurements( + timeRangeFlow: StateFlow, + typesToSmoothAndDisplayFlow: StateFlow> + ): Flow> { + + // 1) Basisdaten je nach Zeitfenster + val baseEnrichedFlow = timeRangeFlow.flatMapLatest { timeRange -> + getTimeFilteredEnrichedMeasurements(timeRange) + } + + // 2) Settings in einem Flow bündeln -> nur noch 1 Flow für alle Glättungs-Parameter + data class SmoothingConfig( + val algorithm: SmoothingAlgorithm, + val alpha: Float, + val window: Int + ) + val smoothingConfigFlow: Flow = + combine(selectedSmoothingAlgorithm, smoothingAlpha, smoothingWindowSize) { algo, alpha, window -> + SmoothingConfig(algo, alpha, window) + } + + // 3) Jetzt nur noch 4 Flows kombinieren + return combine( + baseEnrichedFlow, // Flow> + typesToSmoothAndDisplayFlow, // Flow> + measurementTypes, // Flow> + smoothingConfigFlow // Flow + ) { measurements, typesToSmooth, globalTypes, cfg -> + + if (cfg.algorithm == SmoothingAlgorithm.NONE || measurements.isEmpty() || typesToSmooth.isEmpty()) { + return@combine measurements + } + + // Roh-Serien pro Typ sammeln + val rawSeries = mutableMapOf>>() + typesToSmooth.forEach { typeId -> + globalTypes.find { it.id == typeId }?.takeIf { + it.isEnabled && it.inputType in listOf(InputFieldType.FLOAT, InputFieldType.INT) + }?.let { rawSeries[typeId] = mutableListOf() } + } + + measurements.forEach { m -> + val ts = m.measurementWithValues.measurement.timestamp + m.measurementWithValues.values.forEach { v -> + rawSeries[v.type.id]?.let { list -> + val value = when (v.type.inputType) { + InputFieldType.FLOAT -> v.value.floatValue + InputFieldType.INT -> v.value.intValue?.toFloat() + else -> null + } + value?.let { list.add(ts to it) } + } + } + } + rawSeries.values.forEach { it.sortBy { p -> p.first } } + + // Glätten & auf Timestamps mappen (SMA rechtsbündig auf Fenster) + val smoothedMap: Map> = rawSeries.mapValues { (_, series) -> + val values = series.map { it.second } + val smoothed = when (cfg.algorithm) { + SmoothingAlgorithm.EXPONENTIAL_SMOOTHING -> + CalculationUtil.applyExponentialSmoothing(values, cfg.alpha) + SmoothingAlgorithm.SIMPLE_MOVING_AVERAGE -> + CalculationUtil.applySimpleMovingAverage(values, cfg.window) + else -> values + } + + if (cfg.algorithm == SmoothingAlgorithm.SIMPLE_MOVING_AVERAGE && smoothed.size < series.size) { + val offset = series.size - smoothed.size // == window-1 + smoothed.indices.associate { i -> + series[i + offset].first to smoothed[i] + } + } else { + // EMA oder gleiche Länge + series.indices.zip(smoothed).associate { (i, v) -> series[i].first to v } + } + } + + // Geglättete Werte in die Measurements übernehmen + measurements.map { orig -> + var modified = false + val ts = orig.measurementWithValues.measurement.timestamp + val newValues = orig.measurementWithValues.values.map { v -> + smoothedMap[v.type.id]?.get(ts)?.let { smoothed -> + modified = true + v.copy( + value = v.value.copy( + floatValue = smoothed, + intValue = if (v.type.inputType == InputFieldType.INT) smoothed.roundToInt() else v.value.intValue + ) + ) + } ?: v + } + if (modified) { + orig.copy(measurementWithValues = orig.measurementWithValues.copy(values = newValues)) + } else { + orig + } + } + }.flowOn(Dispatchers.Default) + } + fun getTimeFilteredEnrichedMeasurements( selectedTimeRange: TimeRangeFilter ): Flow> { 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 0d7dee79..54ad500e 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 @@ -56,6 +56,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.health.openscale.R import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType @@ -100,6 +101,7 @@ import com.patrykandpatrick.vico.core.common.component.ShapeComponent import com.patrykandpatrick.vico.core.common.component.TextComponent 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.Instant import java.time.LocalDate @@ -182,11 +184,25 @@ fun LineChart( currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet() } - val timeFilteredData by sharedViewModel.getTimeFilteredEnrichedMeasurements(uiSelectedTimeRange) - .collectAsState(initial = emptyList()) + val timeRangeFlow = remember { MutableStateFlow(uiSelectedTimeRange) } + LaunchedEffect(uiSelectedTimeRange) { + timeRangeFlow.value = uiSelectedTimeRange + } - val fullyFilteredEnrichedMeasurements = remember(timeFilteredData, currentSelectedTypeIntIds) { - sharedViewModel.filterEnrichedMeasurementsByTypes(timeFilteredData, currentSelectedTypeIntIds) + val typesToSmoothFlow = remember { MutableStateFlow(currentSelectedTypeIntIds) } + LaunchedEffect(currentSelectedTypeIntIds) { + typesToSmoothFlow.value = currentSelectedTypeIntIds + } + + val smoothedData by sharedViewModel + .getSmoothedEnrichedMeasurements( + timeRangeFlow = timeRangeFlow, + typesToSmoothAndDisplayFlow = typesToSmoothFlow + ) + .collectAsStateWithLifecycle(initialValue = emptyList()) + + val fullyFilteredEnrichedMeasurements = remember(smoothedData, currentSelectedTypeIntIds) { + sharedViewModel.filterEnrichedMeasurementsByTypes(smoothedData, currentSelectedTypeIntIds) } // Extracting measurements with their values for plotting. diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/ChartSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/ChartSettingsScreen.kt index 0e0b9c97..c964145f 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/ChartSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/ChartSettingsScreen.kt @@ -1,19 +1,30 @@ package com.health.openscale.ui.screen.settings -import androidx.activity.result.launch -import androidx.navigation.NavController - +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.ui.screen.SharedViewModel import kotlinx.coroutines.launch +import kotlin.math.roundToInt +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChartSettingsScreen( navController: NavController, @@ -26,24 +37,31 @@ fun ChartSettingsScreen( } val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current val showDataPoints by sharedViewModel.userSettingRepository.showChartDataPoints.collectAsState(true) + val selectedAlgorithm by sharedViewModel.userSettingRepository.chartSmoothingAlgorithm.collectAsState(SmoothingAlgorithm.NONE) + val currentAlphaState by sharedViewModel.userSettingRepository.chartSmoothingAlpha.collectAsState(0.5f) + val currentWindowSizeState by sharedViewModel.userSettingRepository.chartSmoothingWindowSize.collectAsState(5) + + val availableAlgorithms = remember { SmoothingAlgorithm.values().toList() } + var algorithmDropdownExpanded by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // --- Show Data Points Setting --- Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.setting_show_chart_points), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.titleMedium ) Switch( checked = showDataPoints, @@ -54,5 +72,156 @@ fun ChartSettingsScreen( } ) } + + // --- Smoothing Algorithm Setting --- + ExposedDropdownMenuBox( + expanded = algorithmDropdownExpanded, + onExpandedChange = { algorithmDropdownExpanded = !algorithmDropdownExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedAlgorithm.getDisplayName(context), + onValueChange = { /* Read-only */ }, + readOnly = true, + label = { Text(stringResource(R.string.setting_smoothing_algorithm)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = algorithmDropdownExpanded) }, + modifier = Modifier + .menuAnchor(type = MenuAnchorType.PrimaryEditable) + .fillMaxWidth() + .clickable { algorithmDropdownExpanded = true }, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + ExposedDropdownMenu( + expanded = algorithmDropdownExpanded, + onDismissRequest = { algorithmDropdownExpanded = false }, + modifier = Modifier.exposedDropdownSize(matchTextFieldWidth = true) + ) { + availableAlgorithms.forEach { algorithm -> + DropdownMenuItem( + text = { Text(algorithm.getDisplayName(context)) }, + onClick = { + coroutineScope.launch { + sharedViewModel.userSettingRepository.setChartSmoothingAlgorithm(algorithm) + } + algorithmDropdownExpanded = false + } + ) + } + } + } + + AnimatedVisibility( + visible = selectedAlgorithm != SmoothingAlgorithm.NONE, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + if (selectedAlgorithm == SmoothingAlgorithm.EXPONENTIAL_SMOOTHING) { + val alphaAsInt = (currentAlphaState * 10).roundToInt() + val alphaStepperRange = 1..9 + IntegerStepper( + label = stringResource(R.string.setting_smoothing_alpha), + value = alphaAsInt, + onValueChange = { newIntValue -> + val newFloatValue = newIntValue / 10f + coroutineScope.launch { + sharedViewModel.userSettingRepository.setChartSmoothingAlpha(newFloatValue) + } + }, + valueRange = alphaStepperRange, + valueRepresentation = { intValue -> String.format("%.1f", intValue / 10f) }, + modifier = Modifier.padding(start = 16.dp) + ) + } + + if (selectedAlgorithm == SmoothingAlgorithm.SIMPLE_MOVING_AVERAGE) { + IntegerStepper( + label = stringResource(R.string.setting_smoothing_window_size), + value = currentWindowSizeState, + onValueChange = { newValue -> + coroutineScope.launch { + sharedViewModel.userSettingRepository.setChartSmoothingWindowSize(newValue) + } + }, + valueRange = 2..50, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } +} + +/** + * A reusable Composable for an integer input with + and - buttons. + * (IntegerStepper code remains the same as your provided version) + */ +@Composable +private fun IntegerStepper( + label: String, + value: Int, + onValueChange: (Int) -> Unit, + valueRange: IntRange, + modifier: Modifier = Modifier, + valueRepresentation: ((Int) -> String)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = { + if (value > valueRange.first) { + onValueChange(value - 1) + } + }, + enabled = value > valueRange.first + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = stringResource(R.string.trend_decreased_desc, label) + ) + } + Text( + text = valueRepresentation?.invoke(value) ?: value.toString(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.primary + ) + IconButton( + onClick = { + if (value < valueRange.last) { + onValueChange(value + 1) + } + }, + enabled = value < valueRange.last + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.trend_increased_desc, label) + ) + } + } } } 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 0fb5866b..d1257a57 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -290,6 +290,12 @@ Datenpunkte anzeigen + Glättungsalgorithmus + Alpha + Fenstergröße + Keine Glättung + Gleitender Durchschnitt + Exponentielle Glättung Version: %1$s (%2$s) diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index a141aebc..23a7dac4 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -292,6 +292,12 @@ Show data points + Smoothing Algorithm + Alpha + Window Size + No Smoothing + Moving Average + Exponential Smoothing Version: %1$s (%2$s)