1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-19 23:12:12 +02:00

Add settings for chart data smoothing

This commit is contained in:
oliexdev
2025-08-16 17:54:53 +02:00
parent 4da84c8778
commit 98cc2ba9f5
8 changed files with 511 additions and 15 deletions

View File

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

View File

@@ -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<Boolean>
suspend fun setShowChartDataPoints(show: Boolean)
val chartSmoothingAlgorithm: Flow<SmoothingAlgorithm>
suspend fun setChartSmoothingAlgorithm(algorithm: SmoothingAlgorithm)
val chartSmoothingAlpha: Flow<Float>
suspend fun setChartSmoothingAlpha(alpha: Float)
val chartSmoothingWindowSize: Flow<Int>
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<Boolean> = 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<SmoothingAlgorithm> = 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<Float> = 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<Int> = 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")

View File

@@ -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<Float>, alpha: Float): List<Float> {
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<Float>()
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<Float>, windowSize: Int): List<Float> {
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<Float>()
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
}
}
/**

View File

@@ -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<SmoothingAlgorithm> =
userSettingRepository.chartSmoothingAlgorithm
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = SmoothingAlgorithm.NONE
).also {
LogManager.v(TAG, "selectedSmoothingAlgorithm flow initialized from repository.")
}
val smoothingAlpha: StateFlow<Float> =
userSettingRepository.chartSmoothingAlpha
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = 0.5f
).also {
LogManager.v(TAG, "smoothingAlpha flow initialized from repository.")
}
val smoothingWindowSize: StateFlow<Int> =
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<TimeRangeFilter>,
typesToSmoothAndDisplayFlow: StateFlow<Set<Int>>
): Flow<List<EnrichedMeasurement>> {
// 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<SmoothingConfig> =
combine(selectedSmoothingAlgorithm, smoothingAlpha, smoothingWindowSize) { algo, alpha, window ->
SmoothingConfig(algo, alpha, window)
}
// 3) Jetzt nur noch 4 Flows kombinieren
return combine(
baseEnrichedFlow, // Flow<List<EnrichedMeasurement>>
typesToSmoothAndDisplayFlow, // Flow<Set<Int>>
measurementTypes, // Flow<List<MeasurementType>>
smoothingConfigFlow // Flow<SmoothingConfig>
) { measurements, typesToSmooth, globalTypes, cfg ->
if (cfg.algorithm == SmoothingAlgorithm.NONE || measurements.isEmpty() || typesToSmooth.isEmpty()) {
return@combine measurements
}
// Roh-Serien pro Typ sammeln
val rawSeries = mutableMapOf<Int, MutableList<Pair<Long, Float>>>()
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<Int, Map<Long, Float>> = 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<List<EnrichedMeasurement>> {

View File

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

View File

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

View File

@@ -290,6 +290,12 @@
<!-- Diagramm Einstellungen -->
<string name="setting_show_chart_points">Datenpunkte anzeigen</string>
<string name="setting_smoothing_algorithm">Glättungsalgorithmus</string>
<string name="setting_smoothing_alpha">Alpha</string>
<string name="setting_smoothing_window_size">Fenstergröße</string>
<string name="smoothing_algorithm_none">Keine Glättung</string>
<string name="smoothing_algorithm_sma">Gleitender Durchschnitt</string>
<string name="smoothing_algorithm_ses">Exponentielle Glättung</string>
<!-- Über-Bildschirm & Diagnose -->
<string name="version_info">Version: %1$s (%2$s)</string>

View File

@@ -292,6 +292,12 @@
<!-- Chart Settings Screen -->
<string name="setting_show_chart_points">Show data points</string>
<string name="setting_smoothing_algorithm">Smoothing Algorithm</string>
<string name="setting_smoothing_alpha">Alpha</string>
<string name="setting_smoothing_window_size">Window Size</string>
<string name="smoothing_algorithm_none">No Smoothing</string>
<string name="smoothing_algorithm_sma">Moving Average</string>
<string name="smoothing_algorithm_ses">Exponential Smoothing</string>
<!-- About Screen & Diagnostics -->
<string name="version_info">Version: %1$s (%2$s)</string>