Add settings for custom cadence, speed ranges (#16)

* Add settings for custom cadence, speed ranges

* Remove unused imports
This commit is contained in:
timklge 2025-01-07 19:32:57 +01:00 committed by GitHub
parent 546f219d6d
commit 4bbf79ecb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 182 additions and 108 deletions

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karoopowerbar"
minSdk = 26
targetSdk = 33
versionCode = 10
versionName = "1.3.1"
versionCode = 11
versionName = "1.3.2"
}
signingConfigs {

View File

@ -3,9 +3,9 @@
"packageName": "de.timklge.karoopowerbar",
"iconUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/karoo-powerbar.png",
"latestApkUrl": "https://github.com/timklge/karoo-powerbar/releases/latest/download/app-release.apk",
"latestVersion": "1.3.1",
"latestVersionCode": 10,
"latestVersion": "1.3.2",
"latestVersionCode": 11,
"developer": "timklge",
"description": "Adds a colored power bar to the bottom of the screen",
"releaseNotes": "Add size setting, cadence and speed data sources"
"releaseNotes": "Add size setting, cadence and speed data sources with custom ranges"
}

View File

@ -24,6 +24,7 @@ class CustomProgressBar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
var progress: Double = 0.5
var showValueIfNull: Boolean = false
var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = ""
var showLabel: Boolean = true
@ -104,7 +105,7 @@ class CustomProgressBar @JvmOverloads constructor(
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + size.barHeight, backgroundPaint)
if (progress > 0.0) {
if (progress > 0.0 || showValueIfNull) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)
@ -140,7 +141,7 @@ class CustomProgressBar @JvmOverloads constructor(
canvas.drawRect(0f, canvas.height.toFloat() - size.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint)
if (progress > 0.0) {
if (progress > 0.0 || showValueIfNull) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)

View File

@ -1,10 +1,5 @@
package de.timklge.karoopowerbar
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import de.timklge.karoopowerbar.screens.SelectedSource
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState
@ -14,49 +9,10 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings")
@Serializable
data class PowerbarSettings(
val source: SelectedSource = SelectedSource.POWER,
val topBarSource: SelectedSource = SelectedSource.NONE,
val onlyShowWhileRiding: Boolean = true,
val showLabelOnBars: Boolean = true,
val useZoneColors: Boolean = true,
val barSize: CustomProgressBarSize = CustomProgressBarSize.MEDIUM
){
companion object {
val defaultSettings = Json.encodeToString(PowerbarSettings())
}
}
suspend fun saveSettings(context: Context, settings: PowerbarSettings) {
context.dataStore.edit { t ->
t[settingsKey] = Json.encodeToString(settings)
}
}
fun Context.streamSettings(): Flow<PowerbarSettings> {
return dataStore.data.map { settingsJson ->
try {
jsonWithUnknownKeys.decodeFromString<PowerbarSettings>(
settingsJson[settingsKey] ?: PowerbarSettings.defaultSettings
)
} catch(e: Throwable){
Log.e(KarooPowerbarExtension.TAG, "Failed to read preferences", e)
jsonWithUnknownKeys.decodeFromString<PowerbarSettings>(PowerbarSettings.defaultSettings)
}
}.distinctUntilChanged()
}
fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
return callbackFlow {
val listenerId = addConsumer(OnStreamState.StartStreaming(dataTypeId)) { event: OnStreamState ->

View File

@ -10,7 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.3.1") {
class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.3.2") {
companion object {
const val TAG = "karoo-powerbar"

View File

@ -1,14 +1,11 @@
package de.timklge.karoopowerbar
import android.R
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Text
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore

View File

@ -0,0 +1,55 @@
package de.timklge.karoopowerbar
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import de.timklge.karoopowerbar.screens.SelectedSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val settingsKey = stringPreferencesKey("settings")
@Serializable
data class PowerbarSettings(
val source: SelectedSource = SelectedSource.POWER,
val topBarSource: SelectedSource = SelectedSource.NONE,
val onlyShowWhileRiding: Boolean = true,
val showLabelOnBars: Boolean = true,
val useZoneColors: Boolean = true,
val barSize: CustomProgressBarSize = CustomProgressBarSize.MEDIUM,
val minCadence: Int = defaultMinCadence, val maxCadence: Int = defaultMaxCadence,
val minSpeed: Float = defaultMinSpeedMs, val maxSpeed: Float = defaultMaxSpeedMs, // 50 km/h in m/s
){
companion object {
val defaultSettings = Json.encodeToString(PowerbarSettings())
val defaultMinSpeedMs = 0f
val defaultMaxSpeedMs = 13.89f
val defaultMinCadence = 50
val defaultMaxCadence = 120
}
}
suspend fun saveSettings(context: Context, settings: PowerbarSettings) {
context.dataStore.edit { t ->
t[settingsKey] = Json.encodeToString(settings)
}
}
fun Context.streamSettings(): Flow<PowerbarSettings> {
return dataStore.data.map { settingsJson ->
try {
jsonWithUnknownKeys.decodeFromString<PowerbarSettings>(
settingsJson[settingsKey] ?: PowerbarSettings.defaultSettings
)
} catch(e: Throwable){
Log.e(KarooPowerbarExtension.TAG, "Failed to read preferences", e)
jsonWithUnknownKeys.decodeFromString<PowerbarSettings>(PowerbarSettings.defaultSettings)
}
}.distinctUntilChanged()
}

View File

@ -16,6 +16,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import androidx.annotation.ColorRes
import de.timklge.karoopowerbar.KarooPowerbarExtension.Companion.TAG
import de.timklge.karoopowerbar.screens.SelectedSource
import io.hammerhead.karooext.KarooSystemService
@ -150,28 +151,6 @@ class Window(
}
}
companion object {
val speedZones = listOf(
UserProfile.Zone(0, 9),
UserProfile.Zone(10, 19),
UserProfile.Zone(20, 24),
UserProfile.Zone(25, 29),
UserProfile.Zone(30, 34),
UserProfile.Zone(34, 39),
UserProfile.Zone(40, 44),
)
val cadenceZones = listOf(
UserProfile.Zone(0, 59),
UserProfile.Zone(60, 79),
UserProfile.Zone(80, 89),
UserProfile.Zone(90, 99),
UserProfile.Zone(100, 109),
UserProfile.Zone(110, 119),
UserProfile.Zone(120, 129),
)
}
private suspend fun streamSpeed(smoothed: Boolean) {
val speedFlow = karooSystem.streamDataFlow(if(smoothed) DataType.Type.SMOOTHED_3S_AVERAGE_SPEED else DataType.Type.SPEED)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
@ -185,20 +164,23 @@ class Window(
.combine(settingsFlow) { streamData, settings -> streamData.copy(settings = settings) }
.distinctUntilChanged()
.collect { streamData ->
val valueMetersPerSecond = streamData.value?.roundToInt()
val valueMetersPerSecond = streamData.value
val value = when (streamData.userProfile.preferredUnit.distance){
UserProfile.PreferredUnit.UnitType.IMPERIAL -> valueMetersPerSecond?.times(2.23694)
else -> valueMetersPerSecond?.times(3.6)
}?.roundToInt()
if (value != null) {
val minSpeed = speedZones.first().min
val maxSpeed = speedZones.last().min + 5
if (value != null && valueMetersPerSecond != null) {
val minSpeed = streamData.settings?.minSpeed ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress =
remap(value.toDouble(), minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0)
remap(valueMetersPerSecond, minSpeed.toDouble(), maxSpeed.toDouble(), 0.0, 1.0)
powerbar.showValueIfNull = valueMetersPerSecond != 0.0
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(speedZones, value)?.colorResource ?: R.color.zone7)
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
@ -209,6 +191,7 @@ class Window(
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.showValueIfNull = false
powerbar.label = "?"
Log.d(TAG, "Speed: Unavailable")
@ -233,13 +216,16 @@ class Window(
val value = streamData.value?.roundToInt()
if (value != null) {
val minCadence = cadenceZones.first().min
val maxCadence = cadenceZones.last().min + 5
val minCadence = streamData.settings?.minCadence ?: PowerbarSettings.defaultMinCadence
val maxCadence = streamData.settings?.maxCadence ?: PowerbarSettings.defaultMaxCadence
val progress =
remap(value.toDouble(), minCadence.toDouble(), maxCadence.toDouble(), 0.0, 1.0)
@ColorRes val zoneColorRes = Zone.entries[(progress * Zone.entries.size).roundToInt().coerceIn(0..<Zone.entries.size)].colorResource
powerbar.showValueIfNull = value != 0
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(cadenceZones, value)?.colorResource ?: R.color.zone7)
context.getColor(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
@ -250,6 +236,7 @@ class Window(
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.showValueIfNull = false
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")

View File

@ -10,7 +10,6 @@ import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

View File

@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absolutePadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
@ -23,6 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -36,6 +39,7 @@ 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.compose.LifecycleResumeEffect
@ -43,20 +47,25 @@ import de.timklge.karoopowerbar.CustomProgressBarSize
import de.timklge.karoopowerbar.PowerbarSettings
import de.timklge.karoopowerbar.saveSettings
import de.timklge.karoopowerbar.streamSettings
import de.timklge.karoopowerbar.streamUserProfile
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
enum class SelectedSource(val id: String, val label: String) {
NONE("none", "None"),
HEART_RATE("hr", "Heart Rate"),
POWER("power", "Power"),
POWER_3S("power_3s", "Power (3 second avg)"),
POWER_10S("power_10s", "Power (10 second avg)"),
POWER_3S("power_3s", "Power (3 sec avg)"),
POWER_10S("power_10s", "Power (10 sec avg)"),
SPEED("speed", "Speed"),
SPEED_3S("speed_3s", "Speed (3 second avg"),
SPEED_3S("speed_3s", "Speed (3 sec avg"),
CADENCE("cadence", "Cadence"),
CADENCE_3S("cadence_3s", "Cadence (3 second avg)"),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"),
}
@OptIn(ExperimentalMaterial3Api::class)
@ -79,16 +88,36 @@ fun MainScreen() {
var showLabelOnBars by remember { mutableStateOf(true) }
var barSize by remember { mutableStateOf(CustomProgressBarSize.MEDIUM) }
var minCadence by remember { mutableStateOf("0") }
var maxCadence by remember { mutableStateOf("0") }
var minSpeed by remember { mutableStateOf("0") }
var maxSpeed by remember { mutableStateOf("0") }
var isImperial by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
karooSystem.streamUserProfile().distinctUntilChanged().collect {
isImperial = it.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
}
}
LaunchedEffect(isImperial) {
givenPermissions = Settings.canDrawOverlays(ctx)
ctx.streamSettings().collect { settings ->
bottomSelectedSource = settings.source
topSelectedSource = settings.topBarSource
onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors
barSize = settings.barSize
ctx.streamSettings()
.combine(karooSystem.streamUserProfile()) { settings, profile -> settings to profile }
.distinctUntilChanged()
.collect { (settings, profile) ->
bottomSelectedSource = settings.source
topSelectedSource = settings.topBarSource
onlyShowWhileRiding = settings.onlyShowWhileRiding
showLabelOnBars = settings.showLabelOnBars
colorBasedOnZones = settings.useZoneColors
barSize = settings.barSize
minCadence = settings.minCadence.toString()
maxCadence = settings.maxCadence.toString()
isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
minSpeed = (if(isImperial) settings.minSpeed * 2.23694f else settings.minSpeed * 3.6f).roundToInt().toString()
maxSpeed = (if(isImperial) settings.maxSpeed * 2.23694f else settings.maxSpeed * 3.6f).roundToInt().toString()
}
}
@ -149,6 +178,50 @@ fun MainScreen() {
}
}
if (topSelectedSource == SelectedSource.SPEED || topSelectedSource == SelectedSource.SPEED_3S ||
bottomSelectedSource == SelectedSource.SPEED || bottomSelectedSource == SelectedSource.SPEED_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minSpeed, modifier = Modifier.weight(1f).absolutePadding(right = 2.dp),
onValueChange = { minSpeed = it },
label = { Text("Min Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = maxSpeed, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp),
onValueChange = { maxSpeed = it },
label = { Text("Max Speed") },
suffix = { Text(if (isImperial) "mph" else "kph") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
if (bottomSelectedSource == SelectedSource.CADENCE || topSelectedSource == SelectedSource.CADENCE ||
bottomSelectedSource == SelectedSource.CADENCE_3S || topSelectedSource == SelectedSource.CADENCE_3S){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = minCadence, modifier = Modifier.weight(1f).absolutePadding(right = 2.dp),
onValueChange = { minCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = maxCadence, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp),
onValueChange = { maxCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = colorBasedOnZones, onCheckedChange = { colorBasedOnZones = it})
Spacer(modifier = Modifier.width(10.dp))
@ -170,17 +243,23 @@ fun MainScreen() {
FilledTonalButton(modifier = Modifier
.fillMaxWidth()
.height(50.dp), onClick = {
val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
useZoneColors = colorBasedOnZones,
barSize = barSize
)
val minSpeedSetting = (minSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMinSpeedMs
val maxSpeedSetting = (maxSpeed.toIntOrNull()?.toFloat()?.div((if(isImperial) 2.23694f else 3.6f))) ?: PowerbarSettings.defaultMaxSpeedMs
coroutineScope.launch {
saveSettings(ctx, newSettings)
savedDialogVisible = true
}
val newSettings = PowerbarSettings(
source = bottomSelectedSource, topBarSource = topSelectedSource,
onlyShowWhileRiding = onlyShowWhileRiding, showLabelOnBars = showLabelOnBars,
useZoneColors = colorBasedOnZones,
minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence,
maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence,
minSpeed = minSpeedSetting, maxSpeed = maxSpeedSetting,
barSize = barSize
)
coroutineScope.launch {
saveSettings(ctx, newSettings)
savedDialogVisible = true
}
}) {
Icon(Icons.Default.Done, contentDescription = "Save")
Spacer(modifier = Modifier.width(5.dp))