mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-20 07:21:40 +02:00
Add settings for chart data smoothing
This commit is contained in:
@@ -340,3 +340,13 @@ enum class TimeRangeFilter(@StringRes val displayNameResId: Int) {
|
|||||||
return context.getString(displayNameResId)
|
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.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.health.openscale.core.data.SmoothingAlgorithm
|
||||||
import com.health.openscale.core.utils.LogManager
|
import com.health.openscale.core.utils.LogManager
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -58,7 +59,10 @@ object UserPreferenceKeys {
|
|||||||
val SAVED_BLUETOOTH_SCALE_NAME = stringPreferencesKey("saved_bluetooth_scale_name")
|
val SAVED_BLUETOOTH_SCALE_NAME = stringPreferencesKey("saved_bluetooth_scale_name")
|
||||||
|
|
||||||
// Settings for chart
|
// 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)
|
// Context strings for screen-specific settings (can be used as prefixes for dynamic keys)
|
||||||
const val OVERVIEW_SCREEN_CONTEXT = "overview_screen"
|
const val OVERVIEW_SCREEN_CONTEXT = "overview_screen"
|
||||||
@@ -96,6 +100,15 @@ interface UserSettingsRepository {
|
|||||||
val showChartDataPoints: Flow<Boolean>
|
val showChartDataPoints: Flow<Boolean>
|
||||||
suspend fun setShowChartDataPoints(show: 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
|
// Generic Settings Accessors
|
||||||
/**
|
/**
|
||||||
* Observes a setting with the given key name and default value.
|
* 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(
|
override val showChartDataPoints: Flow<Boolean> = observeSetting(
|
||||||
UserPreferenceKeys.SHOW_CHART_DATA_POINTS.name,
|
UserPreferenceKeys.CHART_SHOW_DATA_POINTS.name,
|
||||||
true
|
true
|
||||||
).catch { exception ->
|
).catch { exception ->
|
||||||
LogManager.e(TAG, "Error observing showChartDataPoints", exception)
|
LogManager.e(TAG, "Error observing showChartDataPoints", exception)
|
||||||
@@ -264,7 +277,60 @@ class UserSettingsRepositoryImpl(context: Context) : UserSettingsRepository {
|
|||||||
|
|
||||||
override suspend fun setShowChartDataPoints(show: Boolean) {
|
override suspend fun setShowChartDataPoints(show: Boolean) {
|
||||||
LogManager.d(TAG, "Setting showChartDataPoints to: $show")
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
@@ -46,6 +46,84 @@ object CalculationUtil {
|
|||||||
fun roundTo(value: Float): Float {
|
fun roundTo(value: Float): Float {
|
||||||
return (value * 100).toInt() / 100.0f
|
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.app.Application
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.StringRes
|
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.material3.SnackbarDuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.geometry.isEmpty
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.values
|
import androidx.core.graphics.values
|
||||||
import androidx.lifecycle.ViewModel
|
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.MeasurementType
|
||||||
import com.health.openscale.core.data.MeasurementTypeKey
|
import com.health.openscale.core.data.MeasurementTypeKey
|
||||||
import com.health.openscale.core.data.MeasurementValue
|
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.TimeRangeFilter
|
||||||
import com.health.openscale.core.data.Trend
|
import com.health.openscale.core.data.Trend
|
||||||
import com.health.openscale.core.data.User
|
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.model.MeasurementWithValues
|
||||||
import com.health.openscale.core.utils.LogManager
|
import com.health.openscale.core.utils.LogManager
|
||||||
import com.health.openscale.core.database.UserSettingsRepository
|
import com.health.openscale.core.database.UserSettingsRepository
|
||||||
|
import com.health.openscale.core.utils.CalculationUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -66,6 +74,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val TAG = "SharedViewModel"
|
private const val TAG = "SharedViewModel"
|
||||||
|
|
||||||
@@ -473,6 +482,142 @@ class SharedViewModel(
|
|||||||
LogManager.v(TAG, "enrichedMeasurementsFlow initialized. (Data Enrichment Flow)")
|
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(
|
fun getTimeFilteredEnrichedMeasurements(
|
||||||
selectedTimeRange: TimeRangeFilter
|
selectedTimeRange: TimeRangeFilter
|
||||||
): Flow<List<EnrichedMeasurement>> {
|
): Flow<List<EnrichedMeasurement>> {
|
||||||
|
@@ -56,6 +56,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.health.openscale.R
|
import com.health.openscale.R
|
||||||
import com.health.openscale.core.data.InputFieldType
|
import com.health.openscale.core.data.InputFieldType
|
||||||
import com.health.openscale.core.data.MeasurementType
|
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.component.TextComponent
|
||||||
import com.patrykandpatrick.vico.core.common.data.ExtraStore
|
import com.patrykandpatrick.vico.core.common.data.ExtraStore
|
||||||
import com.patrykandpatrick.vico.core.common.shape.CorneredShape
|
import com.patrykandpatrick.vico.core.common.shape.CorneredShape
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -182,11 +184,25 @@ fun LineChart(
|
|||||||
currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet()
|
currentSelectedTypeIdsStrings.mapNotNull { stringId: String -> stringId.toIntOrNull() }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeFilteredData by sharedViewModel.getTimeFilteredEnrichedMeasurements(uiSelectedTimeRange)
|
val timeRangeFlow = remember { MutableStateFlow(uiSelectedTimeRange) }
|
||||||
.collectAsState(initial = emptyList())
|
LaunchedEffect(uiSelectedTimeRange) {
|
||||||
|
timeRangeFlow.value = uiSelectedTimeRange
|
||||||
|
}
|
||||||
|
|
||||||
val fullyFilteredEnrichedMeasurements = remember(timeFilteredData, currentSelectedTypeIntIds) {
|
val typesToSmoothFlow = remember { MutableStateFlow(currentSelectedTypeIntIds) }
|
||||||
sharedViewModel.filterEnrichedMeasurementsByTypes(timeFilteredData, 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.
|
// Extracting measurements with their values for plotting.
|
||||||
|
@@ -1,19 +1,30 @@
|
|||||||
package com.health.openscale.ui.screen.settings
|
package com.health.openscale.ui.screen.settings
|
||||||
|
|
||||||
import androidx.activity.result.launch
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.navigation.NavController
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
import com.health.openscale.R
|
import com.health.openscale.R
|
||||||
|
import com.health.openscale.core.data.SmoothingAlgorithm
|
||||||
import com.health.openscale.ui.screen.SharedViewModel
|
import com.health.openscale.ui.screen.SharedViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChartSettingsScreen(
|
fun ChartSettingsScreen(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
@@ -26,24 +37,31 @@ fun ChartSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
val showDataPoints by sharedViewModel.userSettingRepository.showChartDataPoints.collectAsState(true)
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
// --- Show Data Points Setting ---
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.setting_show_chart_points),
|
text = stringResource(R.string.setting_show_chart_points),
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
Switch(
|
Switch(
|
||||||
checked = showDataPoints,
|
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 -->
|
<!-- Diagramm Einstellungen -->
|
||||||
<string name="setting_show_chart_points">Datenpunkte anzeigen</string>
|
<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 -->
|
<!-- Über-Bildschirm & Diagnose -->
|
||||||
<string name="version_info">Version: %1$s (%2$s)</string>
|
<string name="version_info">Version: %1$s (%2$s)</string>
|
||||||
|
@@ -292,6 +292,12 @@
|
|||||||
|
|
||||||
<!-- Chart Settings Screen -->
|
<!-- Chart Settings Screen -->
|
||||||
<string name="setting_show_chart_points">Show data points</string>
|
<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 -->
|
<!-- About Screen & Diagnostics -->
|
||||||
<string name="version_info">Version: %1$s (%2$s)</string>
|
<string name="version_info">Version: %1$s (%2$s)</string>
|
||||||
|
Reference in New Issue
Block a user