Helnamn i hälsningen, flera egna stänger, kroppsvikt med historik och graf
All checks were successful
release / build-release (push) Successful in 5m16s
All checks were successful
release / build-release (push) Successful in 5m16s
- Hälsningen använder profilens displayName (cachas lokalt) - Skivkalkylatorn: obegränsat antal egna stänger med exakt vikt (13,7 kg funkar) — läggs till/tas bort i inställningarna, visas som chips - Profil: uppdatera kroppsvikten direkt i appen (decimalinmatning); vikthistorik med uuid-baserad rättning och borttagning per mätning (addBodyMeasurement med fallback till updateBodyWeight mot äldre API) - Statistik: kroppsviktsgraf med råpunkter, glidande medelvärde (mjuk linje) och streckad linjär trendlinje - API:t har redan fälten muskel/fett/vatten-% för framtida kroppsanalys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
@@ -160,6 +160,67 @@ class GymRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
suspend fun bodyWeightKg(): Double? = store.bodyWeightKg()
|
||||||
|
suspend fun displayName(): String? = store.displayName()
|
||||||
|
|
||||||
|
/* ---------- Kroppsmätningar ---------- */
|
||||||
|
|
||||||
|
suspend fun fetchBodyMeasurements(): List<BodyMeasurement> {
|
||||||
|
val data = client.execute(
|
||||||
|
"query{bodyMeasurements{id date weightKg musclePercent fatPercent waterPercent}}",
|
||||||
|
token = auth.bearerToken(),
|
||||||
|
)
|
||||||
|
return data["bodyMeasurements"]!!.jsonArray.map { m ->
|
||||||
|
val o = m.jsonObject
|
||||||
|
BodyMeasurement(
|
||||||
|
id = o["id"]!!.jsonPrimitive.content,
|
||||||
|
date = o["date"]?.jsonPrimitive?.contentOrNull() ?: "",
|
||||||
|
weightKg = o["weightKg"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
||||||
|
musclePercent = o["musclePercent"]?.jsonPrimitive?.doubleOrNull,
|
||||||
|
fatPercent = o["fatPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||||
|
waterPercent = o["waterPercent"]?.jsonPrimitive?.doubleOrNull,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ny mätning. Faller tillbaka på gamla updateBodyWeight mot äldre API. */
|
||||||
|
suspend fun addBodyMeasurement(weightKg: Double) {
|
||||||
|
try {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$input: AddBodyMeasurementInput!){addBodyMeasurement(input:\$input){id}}",
|
||||||
|
buildJsonObject { put("input", buildJsonObject { put("weightKg", weightKg) }) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
} catch (e: GraphQlException) {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$w: Float){updateBodyWeight(bodyWeightKg:\$w){bodyWeightKg}}",
|
||||||
|
buildJsonObject { put("w", weightKg) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
store.saveBodyWeight(weightKg)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateBodyMeasurement(id: String, weightKg: Double, dateIso: String?) {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$input: UpdateBodyMeasurementInput!){updateBodyMeasurement(input:\$input){id}}",
|
||||||
|
buildJsonObject {
|
||||||
|
put("input", buildJsonObject {
|
||||||
|
put("id", id)
|
||||||
|
put("weightKg", weightKg)
|
||||||
|
dateIso?.let { put("date", it) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteBodyMeasurement(id: String) {
|
||||||
|
client.execute(
|
||||||
|
"mutation(\$id: UUID!){deleteBodyMeasurement(id:\$id)}",
|
||||||
|
buildJsonObject { put("id", id) },
|
||||||
|
auth.bearerToken(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
|
/** Cachade personbästa som (exerciseTypeId, reps) → bästa vikt. */
|
||||||
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
suspend fun personalBests(): Map<Pair<Int, Int>, Double> =
|
||||||
@@ -239,10 +300,10 @@ class GymRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val prof = client.execute("query{myProfile{bodyWeightKg}}", token = token)
|
val prof = client.execute("query{myProfile{bodyWeightKg displayName}}", token = token)
|
||||||
store.saveBodyWeight(
|
val p = prof["myProfile"]?.jsonObject
|
||||||
prof["myProfile"]?.jsonObject?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull
|
store.saveBodyWeight(p?.get("bodyWeightKg")?.jsonPrimitive?.doubleOrNull)
|
||||||
)
|
store.saveDisplayName(p?.get("displayName")?.jsonPrimitive?.contentOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
val favIds: Set<Int> = runCatching {
|
val favIds: Set<Int> = runCatching {
|
||||||
@@ -733,6 +794,15 @@ data class ExerciseStats(
|
|||||||
val metValue: Double?,
|
val metValue: Double?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BodyMeasurement(
|
||||||
|
val id: String,
|
||||||
|
val date: String,
|
||||||
|
val weightKg: Double,
|
||||||
|
val musclePercent: Double?,
|
||||||
|
val fatPercent: Double?,
|
||||||
|
val waterPercent: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
data class TrendBucket(val label: String, val volumeKg: Double, val sessionCount: Int)
|
||||||
data class PbRecord(val reps: Int, val weight: Double, val date: String)
|
data class PbRecord(val reps: Int, val weight: Double, val date: String)
|
||||||
data class PbEntry(val exerciseName: String, val records: List<PbRecord>)
|
data class PbEntry(val exerciseName: String, val records: List<PbRecord>)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ data class AppSettings(
|
|||||||
val weightStep: Double = 2.5,
|
val weightStep: Double = 2.5,
|
||||||
/** Spara inloggningen krypterat och logga in tyst igen vid behov */
|
/** Spara inloggningen krypterat och logga in tyst igen vid behov */
|
||||||
val autoRelogin: Boolean = true,
|
val autoRelogin: Boolean = true,
|
||||||
/** Egen stångvikt i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
|
/** Egna stångvikter i skivkalkylatorn (utöver snabbvalen 20/0 kg) */
|
||||||
val customBarWeight: Double = 15.0,
|
val customBarWeights: List<Double> = listOf(15.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsStore(private val context: Context) {
|
class SettingsStore(private val context: Context) {
|
||||||
@@ -38,12 +38,29 @@ class SettingsStore(private val context: Context) {
|
|||||||
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
|
defaultRestSeconds = prefs[KEY_REST_SECONDS] ?: 90,
|
||||||
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
|
weightStep = prefs[KEY_WEIGHT_STEP]?.toDoubleOrNull() ?: 2.5,
|
||||||
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true,
|
autoRelogin = prefs[KEY_AUTO_RELOGIN]?.toBooleanStrictOrNull() ?: true,
|
||||||
customBarWeight = prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull() ?: 15.0,
|
customBarWeights = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||||
|
?.mapNotNull { it.toDoubleOrNull() }
|
||||||
|
// migrera från gamla enkel-värdet
|
||||||
|
?: prefs[KEY_CUSTOM_BAR]?.toDoubleOrNull()?.let { listOf(it) }
|
||||||
|
?: listOf(15.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCustomBarWeight(value: Double) {
|
suspend fun addCustomBarWeight(value: Double) {
|
||||||
context.settingsDataStore.edit { it[KEY_CUSTOM_BAR] = value.coerceIn(0.0, 60.0).toString() }
|
val v = value.coerceIn(0.1, 100.0)
|
||||||
|
context.settingsDataStore.edit { prefs ->
|
||||||
|
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||||
|
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||||
|
prefs[KEY_CUSTOM_BARS] = (current + v).distinct().sorted().joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeCustomBarWeight(value: Double) {
|
||||||
|
context.settingsDataStore.edit { prefs ->
|
||||||
|
val current = prefs[KEY_CUSTOM_BARS]?.split(",")
|
||||||
|
?.mapNotNull { it.toDoubleOrNull() } ?: listOf(15.0)
|
||||||
|
prefs[KEY_CUSTOM_BARS] = current.filter { it != value }.joinToString(",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setWeightStep(value: Double) {
|
suspend fun setWeightStep(value: Double) {
|
||||||
@@ -73,5 +90,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
private val KEY_WEIGHT_STEP = stringPreferencesKey("weight_step")
|
private val KEY_WEIGHT_STEP = stringPreferencesKey("weight_step")
|
||||||
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
|
private val KEY_AUTO_RELOGIN = stringPreferencesKey("auto_relogin")
|
||||||
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
|
private val KEY_CUSTOM_BAR = stringPreferencesKey("custom_bar_weight")
|
||||||
|
private val KEY_CUSTOM_BARS = stringPreferencesKey("custom_bar_weights")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ class TokenStore(private val context: Context) {
|
|||||||
suspend fun bodyWeightKg(): Double? =
|
suspend fun bodyWeightKg(): Double? =
|
||||||
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
|
context.authDataStore.data.first()[KEY_BODY_WEIGHT]
|
||||||
|
|
||||||
|
suspend fun saveDisplayName(name: String?) {
|
||||||
|
context.authDataStore.edit {
|
||||||
|
if (name.isNullOrBlank()) it.remove(KEY_DISPLAY_NAME) else it[KEY_DISPLAY_NAME] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun displayName(): String? =
|
||||||
|
context.authDataStore.data.first()[KEY_DISPLAY_NAME]
|
||||||
|
|
||||||
/** Rensar sessionen men behåller vald API-url. */
|
/** Rensar sessionen men behåller vald API-url. */
|
||||||
suspend fun clearSession() {
|
suspend fun clearSession() {
|
||||||
context.authDataStore.edit {
|
context.authDataStore.edit {
|
||||||
@@ -95,5 +104,6 @@ class TokenStore(private val context: Context) {
|
|||||||
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
private val KEY_EXPIRATION = longPreferencesKey("expiration_epoch_ms")
|
||||||
private val KEY_API_URL = stringPreferencesKey("api_url")
|
private val KEY_API_URL = stringPreferencesKey("api_url")
|
||||||
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
|
private val KEY_BODY_WEIGHT = doublePreferencesKey("body_weight_kg")
|
||||||
|
private val KEY_DISPLAY_NAME = stringPreferencesKey("display_name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||||
|
val displayName by viewModel.displayName.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -52,7 +53,10 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("Tjena ${uiState.username}", style = MaterialTheme.typography.headlineSmall)
|
Text(
|
||||||
|
"Tjena ${displayName ?: uiState.username}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
"Redo för ett pass?",
|
"Redo för ett pass?",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ class HomeViewModel(
|
|||||||
|
|
||||||
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
|
||||||
|
|
||||||
|
/** Helnamn från profilen — trevligare hälsning än användarnamnet. */
|
||||||
|
val displayName = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
displayName.value = repo.displayName()
|
||||||
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,28 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.MonitorWeight
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
@@ -30,21 +39,64 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import eu.brassepc.fitnessdroid.data.AuthRepository
|
import eu.brassepc.fitnessdroid.data.AuthRepository
|
||||||
|
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||||
import eu.brassepc.fitnessdroid.data.GymApi
|
import eu.brassepc.fitnessdroid.data.GymApi
|
||||||
|
import eu.brassepc.fitnessdroid.data.GymRepository
|
||||||
import eu.brassepc.fitnessdroid.data.Profile
|
import eu.brassepc.fitnessdroid.data.Profile
|
||||||
import eu.brassepc.fitnessdroid.ui.appContainer
|
import eu.brassepc.fitnessdroid.ui.appContainer
|
||||||
|
import eu.brassepc.fitnessdroid.ui.common.compact
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ProfileViewModel(
|
class ProfileViewModel(
|
||||||
private val gymApi: GymApi,
|
private val gymApi: GymApi,
|
||||||
private val auth: AuthRepository,
|
private val auth: AuthRepository,
|
||||||
|
private val repo: GymRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val profile = MutableStateFlow<Profile?>(null)
|
val profile = MutableStateFlow<Profile?>(null)
|
||||||
|
val measurements = MutableStateFlow<List<BodyMeasurement>>(emptyList())
|
||||||
|
val message = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init { load() }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { profile.value = gymApi.myProfile() }
|
runCatching { profile.value = gymApi.myProfile() }
|
||||||
|
runCatching { measurements.value = repo.fetchBodyMeasurements().reversed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addWeight(weightKg: Double) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repo.addBodyMeasurement(weightKg)
|
||||||
|
message.value = "Vikten sparad"
|
||||||
|
load()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
message.value = "Kunde inte spara — offline?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMeasurement(id: String, weightKg: Double) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repo.updateBodyMeasurement(id, weightKg, null)
|
||||||
|
load()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
message.value = "Kunde inte uppdatera — offline?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMeasurement(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repo.deleteBodyMeasurement(id)
|
||||||
|
load()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
message.value = "Kunde inte ta bort — offline?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +108,7 @@ class ProfileViewModel(
|
|||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val c = appContainer()
|
val c = appContainer()
|
||||||
ProfileViewModel(c.gymApi, c.authRepository)
|
ProfileViewModel(c.gymApi, c.authRepository, c.gymRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +120,38 @@ fun ProfileScreen(
|
|||||||
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
|
viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory),
|
||||||
) {
|
) {
|
||||||
val profile by viewModel.profile.collectAsStateWithLifecycle()
|
val profile by viewModel.profile.collectAsStateWithLifecycle()
|
||||||
|
val measurements by viewModel.measurements.collectAsStateWithLifecycle()
|
||||||
|
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||||
|
var showAddWeight by remember { mutableStateOf(false) }
|
||||||
|
var editTarget by remember { mutableStateOf<BodyMeasurement?>(null) }
|
||||||
|
|
||||||
|
if (showAddWeight) {
|
||||||
|
WeightDialog(
|
||||||
|
title = "Uppdatera kroppsvikt",
|
||||||
|
initial = profile?.bodyWeightKg,
|
||||||
|
onSave = { weight ->
|
||||||
|
viewModel.addWeight(weight)
|
||||||
|
showAddWeight = false
|
||||||
|
},
|
||||||
|
onDismiss = { showAddWeight = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
editTarget?.let { m ->
|
||||||
|
WeightDialog(
|
||||||
|
title = "Rätta mätning ${m.date.take(10)}",
|
||||||
|
initial = m.weightKg,
|
||||||
|
onSave = { weight ->
|
||||||
|
viewModel.updateMeasurement(m.id, weight)
|
||||||
|
editTarget = null
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
viewModel.deleteMeasurement(m.id)
|
||||||
|
editTarget = null
|
||||||
|
},
|
||||||
|
onDismiss = { editTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -86,7 +170,62 @@ fun ProfileScreen(
|
|||||||
ProfileRow("Användarnamn", profile?.username ?: "…")
|
ProfileRow("Användarnamn", profile?.username ?: "…")
|
||||||
profile?.displayName?.let { ProfileRow("Namn", it) }
|
profile?.displayName?.let { ProfileRow("Namn", it) }
|
||||||
profile?.email?.let { ProfileRow("E-post", it) }
|
profile?.email?.let { ProfileRow("E-post", it) }
|
||||||
profile?.bodyWeightKg?.let { ProfileRow("Kroppsvikt", "$it kg") }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MonitorWeight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||||
|
Text("Kroppsvikt", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
profile?.bodyWeightKg?.let { "${it.compact()} kg" } ?: "Inte satt",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = { showAddWeight = true }) { Text("Uppdatera") }
|
||||||
|
}
|
||||||
|
message?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (measurements.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Historik (tryck för att rätta)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
measurements.take(8).forEach { m ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { editTarget = m }
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
m.date.take(10),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${m.weightKg.compact()} kg",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +242,7 @@ fun ProfileScreen(
|
|||||||
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
|
Text("Inställningar", style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
Text(
|
||||||
"Vilotimer, larm, standardvila",
|
"Vilotimer, viktsteg, stänger, inloggning",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -118,6 +257,51 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeightDialog(
|
||||||
|
title: String,
|
||||||
|
initial: Double?,
|
||||||
|
onSave: (Double) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var text by remember {
|
||||||
|
mutableStateOf(initial?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() } ?: "")
|
||||||
|
}
|
||||||
|
val parsed = text.trim().replace(',', '.').toDoubleOrNull()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
label = { Text("Vikt (kg)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
onDelete?.let {
|
||||||
|
TextButton(onClick = it) {
|
||||||
|
Text("Ta bort mätningen", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = { parsed?.let(onSave) },
|
||||||
|
enabled = parsed != null && parsed > 0,
|
||||||
|
) { Text("Spara") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Avbryt") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfileRow(label: String, value: String) {
|
private fun ProfileRow(label: String, value: String) {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
@@ -57,15 +57,11 @@ private fun breakdown(perSideGrams: Int): Pair<List<Plate>, Int> {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PlateCalculatorSheet(
|
fun PlateCalculatorSheet(
|
||||||
weightKg: Double,
|
weightKg: Double,
|
||||||
customBarWeight: Double,
|
customBarWeights: List<Double>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var barKg by remember { mutableStateOf(20.0) }
|
var barKg by remember { mutableStateOf(20.0) }
|
||||||
val barOptions = buildList {
|
val barOptions = (listOf(20.0, 0.0) + customBarWeights).distinct()
|
||||||
add(20.0)
|
|
||||||
add(0.0)
|
|
||||||
if (customBarWeight != 20.0 && customBarWeight != 0.0) add(customBarWeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
|
val perSideGrams = (((weightKg - barKg) / 2.0) * 1000).toInt().coerceAtLeast(0)
|
||||||
val (plates, restGrams) = breakdown(perSideGrams)
|
val (plates, restGrams) = breakdown(perSideGrams)
|
||||||
@@ -80,8 +76,9 @@ fun PlateCalculatorSheet(
|
|||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
barOptions.forEach { option ->
|
items(barOptions.size) { i ->
|
||||||
|
val option = barOptions[i]
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = barKg == option,
|
selected = barKg == option,
|
||||||
onClick = { barKg = option },
|
onClick = { barKg = option },
|
||||||
@@ -89,7 +86,7 @@ fun PlateCalculatorSheet(
|
|||||||
Text(
|
Text(
|
||||||
when (option) {
|
when (option) {
|
||||||
0.0 -> "Utan stång"
|
0.0 -> "Utan stång"
|
||||||
customBarWeight -> "Egen ${option.compact()} kg"
|
20.0 -> "Stång 20 kg"
|
||||||
else -> "Stång ${option.compact()} kg"
|
else -> "Stång ${option.compact()} kg"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ fun SessionScreen(
|
|||||||
plateWeight?.let { weight ->
|
plateWeight?.let { weight ->
|
||||||
PlateCalculatorSheet(
|
PlateCalculatorSheet(
|
||||||
weightKg = weight,
|
weightKg = weight,
|
||||||
customBarWeight = uiState.settings.customBarWeight,
|
customBarWeights = uiState.settings.customBarWeights,
|
||||||
onDismiss = { plateWeight = null },
|
onDismiss = { plateWeight = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.selection.selectable
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
@@ -65,9 +67,8 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
|
fun setWeightStep(value: Double) = viewModelScope.launch { store.setWeightStep(value) }
|
||||||
|
|
||||||
fun adjustCustomBar(delta: Double) = viewModelScope.launch {
|
fun addCustomBar(value: Double) = viewModelScope.launch { store.addCustomBarWeight(value) }
|
||||||
store.setCustomBarWeight(settings.value.customBarWeight + delta)
|
fun removeCustomBar(value: Double) = viewModelScope.launch { store.removeCustomBarWeight(value) }
|
||||||
}
|
|
||||||
|
|
||||||
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
|
fun setAutoRelogin(value: Boolean) = viewModelScope.launch {
|
||||||
store.setAutoRelogin(value)
|
store.setAutoRelogin(value)
|
||||||
@@ -181,27 +182,59 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard(title = "Skivkalkylatorn — egen stångvikt") {
|
SettingsCard(title = "Skivkalkylatorn — egna stänger") {
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(settings.customBarWeights.size) { i ->
|
||||||
|
val bar = settings.customBarWeights[i]
|
||||||
|
androidx.compose.material3.InputChip(
|
||||||
|
selected = false,
|
||||||
|
onClick = { viewModel.removeCustomBar(bar) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (bar % 1.0 == 0.0) "${bar.toInt()} kg" else "$bar kg".replace('.', ',')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Ta bort",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var newBar by androidx.compose.runtime.remember {
|
||||||
|
androidx.compose.runtime.mutableStateOf("")
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
FilledTonalButton(onClick = { viewModel.adjustCustomBar(-2.5) }) { Text("−2,5") }
|
androidx.compose.material3.OutlinedTextField(
|
||||||
Text(
|
value = newBar,
|
||||||
"${if (settings.customBarWeight % 1.0 == 0.0) settings.customBarWeight.toInt().toString() else settings.customBarWeight} kg",
|
onValueChange = { newBar = it },
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
label = { Text("Ny stångvikt (t.ex. 13,7)") },
|
||||||
fontWeight = FontWeight.Bold,
|
singleLine = true,
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal,
|
||||||
|
),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
|
||||||
)
|
)
|
||||||
FilledTonalButton(onClick = { viewModel.adjustCustomBar(2.5) }) { Text("+2,5") }
|
FilledTonalButton(onClick = {
|
||||||
|
newBar.trim().replace(',', '.').toDoubleOrNull()?.let {
|
||||||
|
viewModel.addCustomBar(it)
|
||||||
|
newBar = ""
|
||||||
|
}
|
||||||
|
}) { Text("Lägg till") }
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn — " +
|
"Snabbvalen 20 kg och utan stång finns alltid i kalkylatorn.",
|
||||||
"det här är det tredje alternativet (t.ex. lättare stång).",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||||
|
|||||||
@@ -57,8 +57,14 @@ sealed interface StatsUiState {
|
|||||||
class StatsViewModel(private val repo: GymRepository) : ViewModel() {
|
class StatsViewModel(private val repo: GymRepository) : ViewModel() {
|
||||||
val period = MutableStateFlow("month")
|
val period = MutableStateFlow("month")
|
||||||
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading)
|
val uiState = MutableStateFlow<StatsUiState>(StatsUiState.Loading)
|
||||||
|
val bodyMeasurements = MutableStateFlow<List<eu.brassepc.fitnessdroid.data.BodyMeasurement>>(emptyList())
|
||||||
|
|
||||||
init { load() }
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { bodyMeasurements.value = repo.fetchBodyMeasurements() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setPeriod(id: String) {
|
fun setPeriod(id: String) {
|
||||||
if (period.value == id) return
|
if (period.value == id) return
|
||||||
@@ -211,6 +217,55 @@ fun StatsScreen(viewModel: StatsViewModel = viewModel(factory = StatsViewModel.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
val measurements by viewModel.bodyMeasurements.collectAsStateWithLifecycle()
|
||||||
|
if (measurements.size >= 2) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
Row {
|
||||||
|
Text(
|
||||||
|
"Kroppsvikt",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${measurements.last().weightKg.compact()} kg nu",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
WeightGraph(
|
||||||
|
measurements = measurements,
|
||||||
|
lineColor = MaterialTheme.colorScheme.primary,
|
||||||
|
trendColor = MaterialTheme.colorScheme.tertiary,
|
||||||
|
pointColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
measurements.first().date.take(10),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"— mjuk linje · - - trend",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
measurements.last().date.take(10),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stats.heaviestLift != null || stats.newPrCount > 0) {
|
if (stats.heaviestLift != null || stats.newPrCount > 0) {
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package eu.brassepc.fitnessdroid.ui.stats
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.brassepc.fitnessdroid.data.BodyMeasurement
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kroppsvikt över tid: råpunkter, glidande medelvärde (mjuk linje) och
|
||||||
|
* linjär trendlinje (streckad).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WeightGraph(
|
||||||
|
measurements: List<BodyMeasurement>,
|
||||||
|
lineColor: Color,
|
||||||
|
trendColor: Color,
|
||||||
|
pointColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val points = measurements.mapNotNull { m ->
|
||||||
|
runCatching {
|
||||||
|
OffsetDateTime.parse(m.date).toInstant().toEpochMilli().toFloat() to m.weightKg.toFloat()
|
||||||
|
}.getOrNull()
|
||||||
|
}.sortedBy { it.first }
|
||||||
|
if (points.size < 2) return
|
||||||
|
|
||||||
|
val minX = points.first().first
|
||||||
|
val maxX = points.last().first
|
||||||
|
val minW = points.minOf { it.second }
|
||||||
|
val maxW = points.maxOf { it.second }
|
||||||
|
val padW = ((maxW - minW).takeIf { it > 0f } ?: 1f) * 0.15f
|
||||||
|
val loW = minW - padW
|
||||||
|
val hiW = maxW + padW
|
||||||
|
|
||||||
|
// Glidande medelvärde (fönster 5)
|
||||||
|
val smooth = points.mapIndexed { i, _ ->
|
||||||
|
val from = (i - 2).coerceAtLeast(0)
|
||||||
|
val to = (i + 2).coerceAtMost(points.lastIndex)
|
||||||
|
val slice = points.subList(from, to + 1)
|
||||||
|
points[i].first to slice.map { it.second }.average().toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linjär regression för trendlinjen
|
||||||
|
val n = points.size.toFloat()
|
||||||
|
val meanX = points.map { it.first }.average().toFloat()
|
||||||
|
val meanY = points.map { it.second }.average().toFloat()
|
||||||
|
val denominator = points.sumOf { ((it.first - meanX) * (it.first - meanX)).toDouble() }
|
||||||
|
val slope = if (denominator == 0.0) 0f else {
|
||||||
|
(points.sumOf { ((it.first - meanX) * (it.second - meanY)).toDouble() } / denominator).toFloat()
|
||||||
|
}
|
||||||
|
val intercept = meanY - slope * meanX
|
||||||
|
|
||||||
|
Canvas(modifier = modifier.fillMaxWidth().height(160.dp)) {
|
||||||
|
fun x(v: Float) = (v - minX) / (maxX - minX).coerceAtLeast(1f) * size.width
|
||||||
|
fun y(w: Float) = size.height - (w - loW) / (hiW - loW) * size.height
|
||||||
|
|
||||||
|
// Trendlinje (streckad)
|
||||||
|
drawLine(
|
||||||
|
color = trendColor,
|
||||||
|
start = Offset(0f, y(slope * minX + intercept)),
|
||||||
|
end = Offset(size.width, y(slope * maxX + intercept)),
|
||||||
|
strokeWidth = 3f,
|
||||||
|
pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mjuk linje (glidande medelvärde)
|
||||||
|
val path = Path()
|
||||||
|
smooth.forEachIndexed { i, (px, pw) ->
|
||||||
|
if (i == 0) path.moveTo(x(px), y(pw)) else path.lineTo(x(px), y(pw))
|
||||||
|
}
|
||||||
|
drawPath(path, color = lineColor, style = Stroke(width = 5f))
|
||||||
|
|
||||||
|
// Råpunkter
|
||||||
|
points.forEach { (px, pw) ->
|
||||||
|
drawCircle(color = pointColor, radius = 6f, center = Offset(x(px), y(pw)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user