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:
@@ -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)
|
||||
}
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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>> {
|
||||
|
@@ -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.
|
||||
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user