fix #17: Adds optional custom range inputs for power, heart rate (#18)

* fix #17: Adds optional custom range inputs for power, heart rate data sources

* Update karoo-ext

* Fix display of overlay value if out of range

* Fix custom speed range
This commit is contained in:
timklge 2025-01-24 18:56:09 +01:00 committed by GitHub
parent 4bbf79ecb0
commit c7686e43df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 157 additions and 49 deletions

View File

@ -4,7 +4,9 @@
[![GitHub Downloads (specific asset, all releases)](https://img.shields.io/github/downloads/timklge/karoo-powerbar/app-release.apk)](https://github.com/timklge/karoo-powerbar/releases)
![GitHub License](https://img.shields.io/github/license/timklge/karoo-powerbar)
Simple karoo extension that shows an overlay power bar at the edge of the screen. For Karoo 2 and Karoo 3 devices.
Simple karoo extension that shows an overlay power bar at the edge of the screen, comparable to the
dedicated LEDs featured on Wahoo devices.
For Karoo 2 and Karoo 3 devices.
![Powerbar](powerbar0.png)
![Settings](powerbar1.png)

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karoopowerbar"
minSdk = 26
targetSdk = 33
versionCode = 11
versionName = "1.3.2"
versionCode = 12
versionName = "1.3.3"
}
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.2",
"latestVersionCode": 11,
"latestVersion": "1.3.3",
"latestVersionCode": 12,
"developer": "timklge",
"description": "Adds a colored power bar to the bottom of the screen",
"releaseNotes": "Add size setting, cadence and speed data sources with custom ranges"
"releaseNotes": "Adds option to set a custom range for power and heart rate bar"
}

View File

@ -23,8 +23,7 @@ enum class CustomProgressBarSize(val id: String, val label: String, val fontSize
class CustomProgressBar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
var progress: Double = 0.5
var showValueIfNull: Boolean = false
var progress: Double? = 0.5
var location: PowerbarLocation = PowerbarLocation.BOTTOM
var label: String = ""
var showLabel: Boolean = true
@ -99,13 +98,13 @@ class CustomProgressBar @JvmOverloads constructor(
val rect = RectF(
1f,
15f,
((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(),
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
15f + size.barHeight
)
canvas.drawRect(0f, 15f, canvas.width.toFloat(), 15f + size.barHeight, backgroundPaint)
if (progress > 0.0 || showValueIfNull) {
if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)
@ -135,13 +134,13 @@ class CustomProgressBar @JvmOverloads constructor(
val rect = RectF(
1f,
canvas.height.toFloat() - 1f - size.barHeight,
((canvas.width.toDouble() - 1f) * progress.coerceIn(0.0, 1.0)).toFloat(),
((canvas.width.toDouble() - 1f) * (progress ?: 0.0).coerceIn(0.0, 1.0)).toFloat(),
canvas.height.toFloat()
)
canvas.drawRect(0f, canvas.height.toFloat() - size.barHeight, canvas.width.toFloat(), canvas.height.toFloat(), backgroundPaint)
if (progress > 0.0 || showValueIfNull) {
if (progress != null) {
canvas.drawRoundRect(rect, 2f, 2f, blurPaint)
canvas.drawRoundRect(rect, 2f, 2f, linePaint)

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.2") {
class KarooPowerbarExtension : KarooExtension("karoo-powerbar", "1.3.3") {
companion object {
const val TAG = "karoo-powerbar"

View File

@ -25,13 +25,16 @@ data class PowerbarSettings(
val minCadence: Int = defaultMinCadence, val maxCadence: Int = defaultMaxCadence,
val minSpeed: Float = defaultMinSpeedMs, val maxSpeed: Float = defaultMaxSpeedMs, // 50 km/h in m/s
val minPower: Int? = null, val maxPower: Int? = null,
val minHr: Int? = null, val maxHr: Int? = null,
val useCustomHrRange: Boolean = false, val useCustomPowerRange: Boolean = false
){
companion object {
val defaultSettings = Json.encodeToString(PowerbarSettings())
val defaultMinSpeedMs = 0f
val defaultMaxSpeedMs = 13.89f
val defaultMinCadence = 50
val defaultMaxCadence = 120
const val defaultMinSpeedMs = 0f
const val defaultMaxSpeedMs = 13.89f
const val defaultMinCadence = 50
const val defaultMaxCadence = 120
}
}

View File

@ -69,7 +69,7 @@ class Window(
layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
rootView = layoutInflater.inflate(R.layout.popup_window, null)
powerbar = rootView.findViewById(R.id.progressBar)
powerbar.progress = 0.0
powerbar.progress = null
windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
val displayMetrics = DisplayMetrics()
@ -119,7 +119,7 @@ class Window(
}
powerbar.progressColor = context.resources.getColor(R.color.zone7)
powerbar.progress = 0.0
powerbar.progress = null
powerbar.location = powerbarLocation
powerbar.showLabel = showLabel
powerbar.size = powerbarSize
@ -175,7 +175,6 @@ class Window(
val maxSpeed = streamData.settings?.maxSpeed ?: PowerbarSettings.defaultMaxSpeedMs
val progress =
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
@ -184,14 +183,13 @@ class Window(
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Speed: $value min: $minSpeed max: $maxSpeed")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.showValueIfNull = false
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Speed: Unavailable")
@ -223,20 +221,18 @@ class Window(
@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(zoneColorRes)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Cadence: $value min: $minCadence max: $maxCadence")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.showValueIfNull = false
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Cadence: Unavailable")
@ -261,23 +257,24 @@ class Window(
val value = streamData.value?.roundToInt()
if (value != null) {
val minHr = streamData.userProfile.restingHr
val maxHr = streamData.userProfile.maxHr
val progress =
remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
val customMinHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.minHr else null
val customMaxHr = if (streamData.settings?.useCustomHrRange == true) streamData.settings.maxHr else null
val minHr = customMinHr ?: streamData.userProfile.restingHr
val maxHr = customMaxHr ?: streamData.userProfile.maxHr
val progress = remap(value.toDouble(), minHr.toDouble(), maxHr.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.heartRateZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.progress = if (value > 0) progress else null
powerbar.label = "$value"
Log.d(TAG, "Hr: $value min: $minHr max: $maxHr")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Hr: Unavailable")
@ -308,23 +305,24 @@ class Window(
val value = streamData.value?.roundToInt()
if (value != null) {
val minPower = streamData.userProfile.powerZones.first().min
val maxPower = streamData.userProfile.powerZones.last().min + 50
val progress =
remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
val customMinPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.minPower else null
val customMaxPower = if (streamData.settings?.useCustomPowerRange == true) streamData.settings.maxPower else null
val minPower = customMinPower ?: streamData.userProfile.powerZones.first().min
val maxPower = customMaxPower ?: (streamData.userProfile.powerZones.last().min + 50)
val progress = remap(value.toDouble(), minPower.toDouble(), maxPower.toDouble(), 0.0, 1.0)
powerbar.progressColor = if (streamData.settings?.useZoneColors == true) {
context.getColor(getZone(streamData.userProfile.powerZones, value)?.colorResource ?: R.color.zone7)
} else {
context.getColor(R.color.zone0)
}
powerbar.progress = progress
powerbar.progress = if (value > 0) progress else null
powerbar.label = "${value}W"
Log.d(TAG, "Power: $value min: $minPower max: $maxPower")
} else {
powerbar.progressColor = context.getColor(R.color.zone0)
powerbar.progress = 0.0
powerbar.progress = null
powerbar.label = "?"
Log.d(TAG, "Power: Unavailable")

View File

@ -32,6 +32,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -41,6 +42,7 @@ 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.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.compose.LifecycleResumeEffect
import de.timklge.karoopowerbar.CustomProgressBarSize
@ -65,7 +67,9 @@ enum class SelectedSource(val id: String, val label: String) {
SPEED("speed", "Speed"),
SPEED_3S("speed_3s", "Speed (3 sec avg"),
CADENCE("cadence", "Cadence"),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)"),
CADENCE_3S("cadence_3s", "Cadence (3 sec avg)");
fun isPower() = this == POWER || this == POWER_3S || this == POWER_10S
}
@OptIn(ExperimentalMaterial3Api::class)
@ -93,10 +97,25 @@ fun MainScreen() {
var minSpeed by remember { mutableStateOf("0") }
var maxSpeed by remember { mutableStateOf("0") }
var isImperial by remember { mutableStateOf(false) }
var customMinPower by remember { mutableStateOf("") }
var customMaxPower by remember { mutableStateOf("") }
var customMinHr by remember { mutableStateOf("") }
var customMaxHr by remember { mutableStateOf("") }
var useCustomPowerRange by remember { mutableStateOf(false) }
var useCustomHrRange by remember { mutableStateOf(false) }
var profileMaxHr by remember { mutableIntStateOf(0) }
var profileRestHr by remember { mutableIntStateOf(0) }
var profileMinPower by remember { mutableIntStateOf(0) }
var profileMaxPower by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
karooSystem.streamUserProfile().distinctUntilChanged().collect {
isImperial = it.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
karooSystem.streamUserProfile().distinctUntilChanged().collect { profileData ->
isImperial = profileData.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
profileMaxHr = profileData.maxHr
profileRestHr = profileData.restingHr
profileMinPower = profileData.powerZones.first().min
profileMaxPower = profileData.powerZones.last().min + 50
}
}
@ -118,6 +137,12 @@ fun MainScreen() {
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()
customMinPower = settings.minPower?.toString() ?: ""
customMaxPower = settings.maxPower?.toString() ?: ""
customMinHr = settings.minHr?.toString() ?: ""
customMaxHr = settings.maxHr?.toString() ?: ""
useCustomPowerRange = settings.useCustomPowerRange
useCustomHrRange = settings.useCustomHrRange
}
}
@ -182,7 +207,9 @@ fun MainScreen() {
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),
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") },
@ -190,7 +217,9 @@ fun MainScreen() {
singleLine = true
)
OutlinedTextField(value = maxSpeed, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp),
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") },
@ -200,11 +229,81 @@ fun MainScreen() {
}
}
if (topSelectedSource.isPower() || bottomSelectedSource.isPower()){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomPowerRange, onCheckedChange = { useCustomPowerRange = it})
Spacer(modifier = Modifier.width(10.dp))
Text("Use custom power range")
}
if(useCustomPowerRange){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinPower, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp),
onValueChange = { customMinPower = it },
label = { Text("Min Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMinPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = customMaxPower, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp),
onValueChange = { customMaxPower = it },
label = { Text("Max Power", fontSize = 12.sp) },
suffix = { Text("W") },
placeholder = { Text("$profileMaxPower") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
}
}
}
if (topSelectedSource == SelectedSource.HEART_RATE || bottomSelectedSource == SelectedSource.HEART_RATE){
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useCustomHrRange, onCheckedChange = { useCustomHrRange = it})
Spacer(modifier = Modifier.width(10.dp))
Text("Use custom HR range")
}
if (useCustomHrRange){
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = customMinHr, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp),
onValueChange = { customMinHr = it },
label = { Text("Min Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileRestHr > 0) Text("$profileRestHr") else Unit },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
OutlinedTextField(value = customMaxHr, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp),
onValueChange = { customMaxHr = it },
label = { Text("Max Hr") },
suffix = { Text("bpm") },
placeholder = { if(profileMaxHr > 0) Text("$profileMaxHr") else Unit },
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),
OutlinedTextField(value = minCadence, modifier = Modifier
.weight(1f)
.absolutePadding(right = 2.dp),
onValueChange = { minCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
@ -212,7 +311,9 @@ fun MainScreen() {
singleLine = true
)
OutlinedTextField(value = maxCadence, modifier = Modifier.weight(1f).absolutePadding(left = 2.dp),
OutlinedTextField(value = maxCadence, modifier = Modifier
.weight(1f)
.absolutePadding(left = 2.dp),
onValueChange = { maxCadence = it },
label = { Text("Min Cadence") },
suffix = { Text("rpm") },
@ -253,7 +354,13 @@ fun MainScreen() {
minCadence = minCadence.toIntOrNull() ?: PowerbarSettings.defaultMinCadence,
maxCadence = maxCadence.toIntOrNull() ?: PowerbarSettings.defaultMaxCadence,
minSpeed = minSpeedSetting, maxSpeed = maxSpeedSetting,
barSize = barSize
minPower = customMinPower.toIntOrNull(),
maxPower = customMaxPower.toIntOrNull(),
minHr = customMinHr.toIntOrNull(),
maxHr = customMaxHr.toIntOrNull(),
barSize = barSize,
useCustomPowerRange = useCustomPowerRange,
useCustomHrRange = useCustomHrRange,
)
coroutineScope.launch {

View File

@ -9,7 +9,6 @@ androidxLifecycle = "2.8.6"
androidxActivity = "1.9.3"
androidxComposeUi = "1.7.4"
androidxComposeMaterial = "1.3.0"
glance = "1.1.1"
kotlinxSerializationJson = "1.7.3"
lifecycleRuntimeKtx = "2.8.7"
navigationRuntimeKtx = "2.8.4"
@ -25,7 +24,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
color = { module = "com.maxkeppeler.sheets-compose-dialogs:color", version.ref = "color" }
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.1" }
hammerhead-karoo-ext = { group = "io.hammerhead", name = "karoo-ext", version = "1.1.2" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }